Implements the user-store + invite-store + admin routes. The whole system is opt-in via siteConfig.authProviders.email.enabled = true; single-user TOTP-only installs see zero behavior change. Backend: - src/security/user-store.js: users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, defensive dataDir resolver. - src/security/invite-store.js: single-use tokens (SHA-256 hashed on disk), TTL, auto-prune, defensive dataDir resolver. - routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist, /admin/invites (CRUD), public /invites/:token (peek + accept). - routes/auth/index.js: wires userStore, gates admin router on email auth being enabled. - src/auth/providers/email.js: verify() enforces allowlist, creates user record, tags req.user; default-enabled flipped to opt-in. - src/auth/providers/totp.js: bootstraps system@totp.local admin on first verify so current DNS2 operator shows in /admin/users. - src/security/audit-logger.js: middleware adds userId/userEmail/ userRole/viaProvider to log details when req.user is tagged. - PUBLIC_ROUTES + CSRF allowlists updated for invite redemption. Frontend: - status/js/admin.js: modal overlay with users list (role-edit, delete), invite form (email/role/TTL), copy-link button, outstanding-invites list with revoke. Exports window.AdminPanel. - status/js/core/init.js: calls AdminPanel.attachTrigger so the Admin button only appears when /me returns isAdmin=true. Tests: 35 new tests across 3 files (user-store, invite-store, auth multistore integration). Full suite: 1298/1298 passing. Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased] section gets the DC-048 entry.
1866 lines
290 KiB
JavaScript
1866 lines
290 KiB
JavaScript
(function(){injectModal("logo-modal",`<div id="logo-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 400px; max-width: 520px;">
|
|
<h3>Dashboard Settings</h3>
|
|
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
|
Customize your dashboard's appearance and system preferences.
|
|
</p>
|
|
|
|
<div class="mb-16">
|
|
<label for="dashboard-title" class="form-label-bold">Dashboard Title:</label>
|
|
<input type="text" id="dashboard-title" placeholder="DashCaddy" maxlength="50" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem;" />
|
|
<p class="tiny-hint">Shown in browser tab and header (max 50 characters)</p>
|
|
</div>
|
|
|
|
<hr class="hr-divider" />
|
|
|
|
<div class="mb-16">
|
|
<label class="form-label-bold">Logo:</label>
|
|
<p class="tiny-hint" style="margin-top: 2px;">Separate logos for dark and light themes, or use the same for both.</p>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
|
|
<div style="flex: 1; text-align: center; padding: 16px 10px; border-radius: 8px; background: #1a1a2e; border: 1px solid rgba(255,255,255,.1);">
|
|
<img id="logo-preview-dark" src="/assets/dashcaddy-logo-dark.png" alt="Dark theme logo" style="max-height: 60px; max-width: 100%;" />
|
|
<p style="font-size: 0.65rem; color: #9aa6bf; margin-top: 6px;">Dark themes</p>
|
|
</div>
|
|
<div style="flex: 1; text-align: center; padding: 16px 10px; border-radius: 8px; background: #f0f0f0; border: 1px solid rgba(0,0,0,.1);">
|
|
<img id="logo-preview-light" src="/assets/dashcaddy-logo-light.png" alt="Light theme logo" style="max-height: 60px; max-width: 100%;" />
|
|
<p style="font-size: 0.65rem; color: #5f6b7a; margin-top: 6px;">Light themes</p>
|
|
</div>
|
|
</div>
|
|
<p id="logo-status" style="font-size: 0.75rem; color: var(--muted); margin-bottom: 12px; text-align: center;">Using default logos</p>
|
|
|
|
<div class="mb-16">
|
|
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.82rem; cursor: pointer; user-select: none;">
|
|
<input type="checkbox" id="logo-same-both" /> Use same logo for both
|
|
</label>
|
|
</div>
|
|
|
|
<div id="logo-dual-uploads" class="mb-16" style="display: flex; gap: 12px;">
|
|
<div style="flex: 1;">
|
|
<label for="logo-upload-dark" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Dark theme logo:</label>
|
|
<input type="file" id="logo-upload-dark" accept="image/*" class="input-card-alt" style="font-size: 0.75rem;" />
|
|
</div>
|
|
<div style="flex: 1;">
|
|
<label for="logo-upload-light" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Light theme logo:</label>
|
|
<input type="file" id="logo-upload-light" accept="image/*" class="input-card-alt" style="font-size: 0.75rem;" />
|
|
</div>
|
|
</div>
|
|
|
|
<div id="logo-single-upload" class="mb-16" style="display: none;">
|
|
<label for="logo-upload-single" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Upload logo:</label>
|
|
<input type="file" id="logo-upload-single" accept="image/*" class="input-card-alt" />
|
|
<p class="tiny-hint">This logo will be used on all themes</p>
|
|
</div>
|
|
|
|
<div class="mb-16">
|
|
<label style="display: block; margin-bottom: 8px;">Logo Position:</label>
|
|
<div id="logo-position-btns" class="flex-row-gap">
|
|
<button type="button" data-pos="left" class="logo-pos-btn btn-option">Left</button>
|
|
<button type="button" data-pos="center" class="logo-pos-btn btn-option">Center</button>
|
|
<button type="button" data-pos="right" class="logo-pos-btn btn-option">Right</button>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="hr-divider" />
|
|
|
|
<div class="mb-16">
|
|
<label class="form-label-bold">Favicon (Browser Tab Icon):</label>
|
|
<div id="favicon-preview-container" style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px; padding: 12px; border-radius: 6px; background: var(--card-bg);">
|
|
<img id="favicon-preview" src="/assets/dashcaddy-favicon.ico" alt="Current favicon" style="width: 32px; height: 32px; image-rendering: pixelated;" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect fill=%22%23667%22 width=%2232%22 height=%2232%22 rx=%224%22/></svg>'" />
|
|
<span id="favicon-status" class="text-tiny-muted">Using DashCaddy favicon</span>
|
|
</div>
|
|
<input type="file" id="favicon-upload" accept="image/png,image/svg+xml" class="input-card-alt" />
|
|
<p class="tiny-hint">Upload PNG or SVG - automatically converted to ICO</p>
|
|
</div>
|
|
|
|
<hr class="hr-divider" />
|
|
|
|
<div class="mb-16">
|
|
<label for="settings-timezone" class="form-label-bold">Timezone:</label>
|
|
<select id="settings-timezone" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem;">
|
|
<!-- Populated by JS with IANA timezones -->
|
|
</select>
|
|
<p class="tiny-hint">Used by all deployed containers. Changes apply to new deployments.</p>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons">
|
|
<button id="logo-reset" style="background: color-mix(in srgb, #ef4444 20%, transparent); border-color: #ef4444; color: #ef4444;">Reset to Default</button>
|
|
<button id="logo-cancel">Cancel</button>
|
|
<button id="logo-save" class="btn-accent">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("logo-modal"),S=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),k=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),P=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),E=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),I=DC.NAME;let L=null,j=null,M=null,O="left",x=I;N?.addEventListener("change",()=>{N.checked?(R.style.display="none",z.style.display="",L=null,j=null):(R.style.display="flex",z.style.display="none",M=null)});function D(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const o=new FileReader;o.onload=a=>e(a.target.result),o.readAsDataURL(t)}P?.addEventListener("change",t=>{D(t.target.files[0],e=>{L=e,S.src=e,k.textContent="New dark logo ready to save"})}),g?.addEventListener("change",t=>{D(t.target.files[0],e=>{j=e,A.src=e,k.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{D(t.target.files[0],e=>{M=e,S.src=e,A.src=e,k.textContent="New logo ready to save (both themes)"})});function y(t){E.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 m(t){x=t||I,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function b(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,S.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,H.src=e.customLogo,S.src=e.customLogo,A.src=e.customLogo),e.isDefault||(k.textContent="Using custom logo"),e.position&&(O=e.position,y(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{O=t.dataset.pos,y(O)})}),document.getElementById("brand")?.addEventListener("click",()=>{L=null,j=null,M=null,P&&(P.value=""),g&&(g.value=""),T&&(T.value=""),N&&(N.checked=!1),R.style.display="flex",z.style.display="none",S.src=w.src,A.src=H.src;const t=w.src.includes("custom-logo")||H.src.includes("custom-logo");k.textContent=t?"Using custom logo":"Using default logos",y(O),$.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||I,e={position:O,dashboardTitle:t};N?.checked&&M?(e.dataDark=M,e.dataLight=M):(L&&(e.dataDark=L),j&&(e.dataLight=j));const o=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(o.ok){const a=await o.json(),d="?t="+Date.now();a.pathDark&&(w.src=a.pathDark+d,S.src=a.pathDark+d),a.pathLight&&(H.src=a.pathLight+d,A.src=a.pathLight+d),y(O),m(t),h.classList.remove("show")}else{const a=await o.json();showNotification("Failed to save: "+a.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&&(w.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",S.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",k.textContent="Using default logos",L=null,j=null,M=null,$.value=I,m(I),O="left",y("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const o=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),d=document.getElementById("favicon-status");o&&(o.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),d&&(d.textContent="Using DashCaddy favicon"),r=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const p=document.getElementById("favicon-preview"),v=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),l=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(l.rel="icon",l.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(l));async function f(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(l.href=e.customFavicon+"?t="+Date.now(),p.src=e.customFavicon+"?t="+Date.now(),v.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 o=new FileReader;o.onload=a=>{r=a.target.result,p.src=r,v.textContent="New favicon ready to save"},o.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(t.ok){const e=await t.json();l.href=e.path+"?t="+Date.now(),p.src=e.path+"?t="+Date.now(),v.textContent="Using custom favicon",r=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),f(),b();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{h.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const o=await fetch("/api/v1/config");o.ok&&(e=(await o.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const o=await fetch("/api/v1/config");if(!o.ok)return;const a=await o.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(o){console.warn("Failed to save timezone:",o.message)}}))})(),window.populateTimezoneSelect=function(h,S){const A=Intl.supportedValuesOf("timeZone"),k=S||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of A){const R=document.createElement("option");R.value=N,R.textContent=N.replace(/_/g," "),N===k&&(R.selected=!0),h.appendChild(R)}},(function(){let h="homelab",S=null;async function A(){try{const D=await fetch("/api/v1/config");if(D.ok&&(S=await D.json(),S&&S.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const k=document.getElementById("setup-timezone");k&&window.populateTimezoneSelect(k);function N(x){document.querySelectorAll(".setup-step").forEach(y=>{y.style.display="none"});const D=document.getElementById(x);D&&(D.style.display="block")}function R(){const x=document.getElementById("setup-summary-content");if(!x)return;let D='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const m=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",v=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=`
|
|
<div>
|
|
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
|
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
|
<div><strong>TLD:</strong> ${m}</div>
|
|
<div><strong>Certificate Authority:</strong> ${b}</div>
|
|
<div><strong>DNS Server:</strong> ${p}:${v}</div>
|
|
<div><strong>Example URLs:</strong> https://uptime${m}, https://nextcloud${m}</div>
|
|
</div>
|
|
</div>
|
|
`}else if(h==="simple"){const m=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=`
|
|
<div>
|
|
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
|
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
|
<div><strong>Access Method:</strong> IP:Port only</div>
|
|
<div><strong>Default IP:</strong> ${m}</div>
|
|
<div><strong>SSL:</strong> None (HTTP only)</div>
|
|
<div><strong>Example URLs:</strong> http://${m}:8080, http://${m}:3000</div>
|
|
</div>
|
|
</div>
|
|
`}else if(h==="public"){const m=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",v=p==="subdirectory"?`https://${m}/sonarr, https://${m}/grafana`:`https://sonarr.${m}, https://grafana.${m}`;D+=`
|
|
<div>
|
|
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
|
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
|
<div><strong>Domain:</strong> ${m}</div>
|
|
<div><strong>SSL:</strong> Let's Encrypt</div>
|
|
<div><strong>Email:</strong> ${b}</div>
|
|
<div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
<div><strong>Example URLs:</strong> ${v}</div>
|
|
</div>
|
|
</div>
|
|
`}const y=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=`
|
|
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${y.replace(/_/g," ")}</div>
|
|
</div>
|
|
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function P(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";x.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()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const y=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",m=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${y}. Settings saved to: ${m}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(x){const D=x.target.value||".home",y=document.getElementById("tld-preview"),m=document.getElementById("tld-preview-2");y&&(y.textContent=D),m&&(m.textContent=D)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(x){x.preventDefault(),N("setup-step-1")});const E=document.getElementById("setup-homelab-next");E&&(E.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",y=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!y){showNotification("Please enter a Certificate Authority name","warning");return}if(!m){showNotification("Please enter your DNS server IP address","warning");return}R()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(x){x.preventDefault(),N("setup-step-1")});const I=document.getElementById("setup-simple-next");I&&(I.onclick=function(x){x.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const L=document.getElementById("setup-public-back");L&&(L.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!y||!y.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-public")});const O=document.getElementById("setup-finish");O&&(O.onclick=function(x){x.preventDefault(),P()}),window.getGlobalConfig=async function(){try{const D=await fetch("/api/v1/config");if(D.ok){const y=await D.json();if(y&&y.setupComplete)return y}}catch{console.warn("Could not fetch config from server")}const x=safeGet("dashcaddy-config");return x?JSON.parse(x):{setupComplete:!1,configurationType:"homelab",tld:".home",caName:"",defaults:{dnsType:"private",sslType:"internal",targetIP:"localhost"}}},window.resetSetupWizard=async function(){if(confirm("Reset DashCaddy configuration? This will show the setup wizard again.")){try{await secureFetch("/api/v1/config",{method:"DELETE"})}catch{console.warn("Could not delete server config")}safeRemove("dashcaddy-setup"),safeRemove("dashcaddy-config"),location.reload()}}})(),(function(){const h=new ErrorHandler;injectModal("app-selector-modal",`<div id="app-selector-modal" class="weather-modal">
|
|
<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>
|
|
<div style="text-align: center; margin-top: 24px;">
|
|
<button id="app-selector-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>`),injectModal("app-deploy-modal",`<div id="app-deploy-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
|
|
<h3 id="app-deploy-title">Deploy Application</h3>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 16px;">
|
|
<!-- Subdomain/Domain -->
|
|
<div>
|
|
<label for="deploy-subdomain" class="form-label-accent-sm">
|
|
\u{1F310} Subdomain or Domain
|
|
</label>
|
|
<input type="text" id="deploy-subdomain" placeholder="uptime"
|
|
style="width: 100%; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.95rem;" />
|
|
<div class="form-hint-sm">
|
|
Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.home</span>
|
|
</div>
|
|
<div id="subpath-compat-warning" style="display: none; padding: 8px 12px; border-radius: 6px; background: color-mix(in srgb, #ff9800 10%, var(--card-bg)); border: 1px solid color-mix(in srgb, #ff9800 30%, transparent); margin-top: 8px; font-size: 0.85rem;"></div>
|
|
</div>
|
|
|
|
<!-- DNS Configuration -->
|
|
<div>
|
|
<label class="form-label-accent">
|
|
\u{1F5C2}\uFE0F DNS Configuration
|
|
</label>
|
|
<div class="flex-col-gap">
|
|
<label class="radio-option">
|
|
<input type="radio" name="dns-type" value="private" checked style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Private DNS (Technitium)</div>
|
|
<div class="text-hint">Use your local DNS server with custom TLD (.sami, .home, etc.)</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="dns-type" value="public" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Public DNS</div>
|
|
<div class="text-hint">Use a real domain (example.com) - requires DNS provider setup</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="dns-type" value="none" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">No DNS (IP:Port only)</div>
|
|
<div class="text-hint">Access via IP address and port - no domain setup</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SSL Configuration -->
|
|
<div>
|
|
<label class="form-label-accent">
|
|
\u{1F512} SSL/TLS Certificate
|
|
</label>
|
|
<div class="flex-col-gap">
|
|
<label class="radio-option">
|
|
<input type="radio" name="ssl-type" value="internal" checked style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Internal CA</div>
|
|
<div class="text-hint">Use Caddy's internal certificate authority (self-signed)</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="ssl-type" value="letsencrypt" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Let's Encrypt</div>
|
|
<div class="text-hint">Free public SSL certificate (requires public domain)</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="ssl-type" value="none" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">No SSL (HTTP only)</div>
|
|
<div class="text-hint">\u26A0\uFE0F Not recommended - traffic will be unencrypted</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Media Library Path (shown for media apps) -->
|
|
<div id="media-path-section" style="display: none;">
|
|
<label class="form-label-accent">
|
|
\u{1F4C1} Media Library Location
|
|
</label>
|
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
|
<!-- Detected mounts from existing media servers -->
|
|
<div id="detected-mounts-container" style="display: none;">
|
|
<div style="font-size: 0.85rem; color: var(--success); margin-bottom: 6px;">
|
|
\u2713 Detected from existing media servers:
|
|
</div>
|
|
<div id="detected-mounts-list" style="display: flex; gap: 8px; flex-wrap: wrap;"></div>
|
|
</div>
|
|
|
|
<!-- Path input with browse button -->
|
|
<div class="flex-row-gap">
|
|
<input type="text" id="deploy-media-path" placeholder="/media/Movies, /media/TVShows"
|
|
class="input-flex" style="font-size: 0.95rem;" />
|
|
<button type="button" id="browse-media-btn" style="padding: 10px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap;">
|
|
\u{1F4C2} Browse
|
|
</button>
|
|
</div>
|
|
|
|
<div id="media-path-description" class="text-hint">
|
|
Select folders from existing servers, browse, or type paths manually. Separate multiple with commas.
|
|
</div>
|
|
|
|
<!-- Selected paths display -->
|
|
<div id="selected-media-paths" style="display: none; flex-wrap: wrap; gap: 6px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plex Claim Token (shown only for Plex deployments) -->
|
|
<div id="plex-claim-section" style="display: none;">
|
|
<label class="form-label-accent">
|
|
\u{1F511} Plex Claim Token
|
|
</label>
|
|
<div class="flex-row-gap">
|
|
<input type="text" id="deploy-plex-claim" placeholder="claim-xxxxxxxxxxxxxxxxxxxx"
|
|
class="input-flex" style="font-size: 0.95rem;" />
|
|
<a href="https://plex.tv/claim" target="_blank" rel="noopener noreferrer"
|
|
style="padding: 10px 16px; background: #e5a00d; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap; text-decoration: none; display: flex; align-items: center;">
|
|
Get Token
|
|
</a>
|
|
</div>
|
|
<div style="font-size: 0.8rem; color: #e5a00d; margin-top: 4px;">
|
|
Token expires in 4 minutes! Get it right before clicking Deploy. Leave empty to configure Plex manually later.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tailscale Security -->
|
|
<div id="tailscale-section">
|
|
<label class="form-label-accent">
|
|
\u{1F510} Tailscale Security
|
|
</label>
|
|
<div class="flex-col-gap">
|
|
<label class="radio-option">
|
|
<input type="checkbox" id="deploy-tailscale-only" style="margin-right: 10px; width: 18px; height: 18px;" />
|
|
<div>
|
|
<div class="fw-500">Tailscale-Only Access</div>
|
|
<div class="text-hint">Restrict this service to Tailscale users only (100.x.x.x IPs)</div>
|
|
</div>
|
|
</label>
|
|
<div id="tailscale-status" style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
<span style="color: var(--muted);">Checking Tailscale status...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Options (Collapsible) -->
|
|
<details>
|
|
<summary style="cursor: pointer; color: var(--accent); font-weight: 500; margin-bottom: 8px;">\u2699\uFE0F Advanced Options</summary>
|
|
<div style="margin-top: 12px; display: grid; gap: 12px;">
|
|
<div>
|
|
<label for="deploy-port" style="display: block; margin-bottom: 6px;">Custom Port (optional)</label>
|
|
<input type="number" id="deploy-port" placeholder="Leave empty for default"
|
|
class="form-input-card" />
|
|
</div>
|
|
<div>
|
|
<label for="deploy-ip" style="display: block; margin-bottom: 6px;">Target IP Address</label>
|
|
<input type="text" id="deploy-ip" value="localhost"
|
|
class="form-input-card" />
|
|
<div class="form-hint-sm">
|
|
Use 'localhost' for same-host containers, or specific IP for remote services
|
|
</div>
|
|
</div>
|
|
<div id="volume-mounts-section" style="display: none;">
|
|
<label class="form-label-accent-sm">\u{1F4C2} Volume Mounts</label>
|
|
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
|
Customize where container data is stored on the host. Media volumes are configured above.
|
|
</div>
|
|
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
|
|
</div>
|
|
<div style="margin-top: 12px;">
|
|
<label class="form-label-accent-sm">\u2699\uFE0F Resource Limits</label>
|
|
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
|
Optional CPU and memory constraints. Leave at 0 for unlimited.
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
|
<div>
|
|
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
|
|
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
|
</div>
|
|
<div>
|
|
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
|
|
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
|
<button id="app-deploy-cancel">Cancel</button>
|
|
<button id="app-deploy-confirm" class="btn-accent">
|
|
\u{1F680} Deploy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const S="custom-apps";let A=null,k=null;const N=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function z(){try{const l=await(await fetch("/api/v1/apps/templates")).json();if(l.success)return A=l.templates,k=l.categories,!0}catch(s){h.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function P(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(l){return h.logError("[AppSelector] Check Port",l,{function:"checkPortAvailability"}),{available:!0}}}async function g(s){try{const r=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(r.success)return r.suggestedPort}catch(l){h.logError("[AppSelector] Get Suggested Port",l,{function:"getSuggestedPort"})}return s}async function T(){if(R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await z()){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}R.innerHTML="";const s={};for(const[r,f]of Object.entries(A)){const u=f.category||"Other";s[u]||(s[u]=[]),s[u].push({id:r,...f})}const l=k?Object.keys(k):Object.keys(s).sort();for(const r of l){const f=s[r];if(!f||f.length===0)continue;f.sort((e,o)=>(o.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const t=k?.[r]||{};u.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(r)}`,t.color&&(u.style.borderBottomColor=t.color),R.appendChild(u),f.forEach(e=>{const o=document.createElement("div");o.className="app-option";const a=e.isDashboardWidget,d=a&&safeGet("widget-"+e.id+"-enabled")!=="false",n=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${d?"#2ecc7130":"#e74c3c30"}; color: ${d?"#2ecc71":"#e74c3c"}; font-weight: 600;">${d?"ON":"OFF"}</div>`:"",i=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";o.innerHTML=`
|
|
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
|
|
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
|
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
|
${n}${i}
|
|
`,a?o.onclick=()=>w(e,o):o.onclick=()=>H(e),R.appendChild(o)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function w(s,l){const r="widget-"+s.id+"-enabled",u=!(safeGet(r)!=="false");safeSet(r,String(u));const t=s.widgetSelector;if(t){const o=document.querySelector(t);o&&(o.style.display=u?"":"none")}const e=l.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=u?"ON":"OFF",e.style.background=u?"#2ecc7130":"#e74c3c30",e.style.color=u?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function H(s){const l=document.getElementById("app-deploy-modal"),r=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),o=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${s.name} container:
|
|
|
|
Container: ${V.name}
|
|
Status: ${V.status}
|
|
Port: ${V.primaryPort||"N/A"}
|
|
|
|
Would you like to use this existing container?
|
|
|
|
Click OK to configure DNS/Caddy for the existing container.
|
|
Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}r.textContent=`Deploy ${s.name}`;const d=s.subdomain||s.id.replace(/-/g,"");f.value=d;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const _=s.subpathSupport||"strip";_==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">ⓘ '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),C=document.querySelector(`input[name="dns-type"][value="${i}"]`),B=document.querySelector(`input[name="ssl-type"][value="${c}"]`);C?C.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,B?B.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",o.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const le=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const de=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=de.indexOf(ee.hostPath);pe>=0?(de.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(de.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=de.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const _=s.mediaMount?.containerPath,W=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
|
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
|
|
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
|
|
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
|
|
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
|
|
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const le=Z.querySelector(".vol-host-path");openFolderBrowser(le)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await P(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span>
|
|
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=`
|
|
<span style="color: #4caf50;">Connected</span>
|
|
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span>
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span>
|
|
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',o.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=f.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}u.textContent=K}f.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),l.classList.add("show"),l.dataset.appTemplate=JSON.stringify(s)}async function E(s){const l=s.appTemplate,r=safeGetJSON(S,[]),f=l._useExisting&&l._existingContainer,u=r.find(t=>t.id===s.subdomain);if(!(u&&!f&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(u){const t=r.indexOf(u);r.splice(t,1),safeSet(S,JSON.stringify(r))}if(f)s.port=l._existingContainer.primaryPort;else{const t=s.port||l.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await P(t);if(!e.available){const o=await g(l.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
|
|
|
Would you like to use port ${o} instead?`))s.port=o;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${l.name} with existing container...`:`Deploying ${l.name}...`,"info",0);try{const t={appId:l.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||l.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};f&&(t.config.useExisting=!0,t.config.existingContainerId=l._existingContainer.id,t.config.existingPort=l._existingContainer.primaryPort,!s.port&&l._existingContainer.primaryPort&&(t.config.port=l._existingContainer.primaryPort));const o=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(o.success){const a={id:s.subdomain,name:l.name,logo:`/assets/${l.id}.png`,containerId:o.containerId,url:o.url,ip:s.ip,appTemplate:l.id,tailscaleOnly:s.tailscaleOnly||!1};r.push(a),safeSet(S,JSON.stringify(r)),window.APPS&&!window.APPS.some(n=>n.id===l.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let d=o.usedExisting?`${l.name} configured with existing container!
|
|
URL: ${o.url}`:`${l.name} deployed successfully!
|
|
URL: ${o.url}`;o.warning&&(d+=`
|
|
|
|
\u26A0 Warning: ${o.warning}`),showNotification(d,"success",8e3),delete l._useExisting,delete l._existingContainer,o.url&&o.url.startsWith("https://")&&$(o.url,l.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const n=o.setupInstructions.join(`
|
|
`);showNotification(`Setup Instructions for ${l.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(t){h.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${l.name}: ${t.message}`,"error",8e3)}}}async function $(s,l){showNotification(`\u23F3 Generating SSL certificate for ${l}...`,"warning",6e4);let r=0;const f=12,u=async()=>{r++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${l} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return r<f?setTimeout(u,5e3):showNotification(`\u26A0\uFE0F ${l} deployed but SSL certificate may still be generating.
|
|
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(u,3e3)}function I(){safeGetJSON(S,[]).forEach(l=>{window.APPS.some(r=>r.id===l.id)||window.APPS.push(l)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const L=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(L.dataset.appTemplate),l=document.getElementById("deploy-media-path").value.trim(),r=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{r.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const f={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:l||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:r.length>0?r:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!f.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!l){showNotification("Please enter a media library path for this application","warning");return}L.classList.remove("show"),E(f)}),wireModal(L);const j=document.getElementById("folder-browser-modal"),M=document.getElementById("folder-browser-path"),O=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let y="",m=[],b=null;window.openFolderBrowser=function(s){b=s,m=s.value.split(",").map(l=>l.trim()).filter(l=>l),y="",v(),p(""),j.classList.add("show")};async function p(s){M.textContent=s||"Select a drive...",O.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const r=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!r.success){O.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(r.error)}</div>`;return}y=r.path||"",M.textContent=y||"Select a drive...";let f="";r.parent&&r.parent!==r.path&&(f+=`<div class="folder-item" data-path="${escapeHtml(r.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>`),r.items.length===0&&!r.parent?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':r.items.length===0?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':r.items.forEach(u=>{const t=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(u.path),o=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`<div class="folder-item" data-path="${escapeHtml(u.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${o}">
|
|
<span style="font-size: 1.2rem;">${t}</span>
|
|
<span style="flex: 1;">${escapeHtml(u.name)}</span>
|
|
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
|
</div>`}),O.innerHTML=f,O.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{p(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const t=m.includes(u.dataset.path);u.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(l){O.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(l.message)}</div>`}}function v(){if(m.length===0){x.style.display="none";return}x.style.display="block",D.innerHTML=m.map(s=>`
|
|
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
|
|
${escapeHtml(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){m=m.filter(l=>l!==s),v(),p(y)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{y&&!m.includes(y)&&(m.push(y),v(),p(y))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{b&&(b.value=m.join(", ")),j.classList.remove("show")}),I()})(),(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>
|
|
|
|
<!-- Step indicator -->
|
|
<div id="recipe-steps" style="display: flex; gap: 4px; margin: 16px 0 24px;">
|
|
<div class="recipe-step active" data-step="1"><span>1</span> Components</div>
|
|
<div class="recipe-step" data-step="2"><span>2</span> Configuration</div>
|
|
<div class="recipe-step" data-step="3"><span>3</span> Review</div>
|
|
<div class="recipe-step" data-step="4"><span>4</span> Progress</div>
|
|
</div>
|
|
|
|
<!-- Step 1: Component Selection -->
|
|
<div id="recipe-step-1" class="recipe-step-panel">
|
|
<label class="form-label-accent-sm">Select Components:</label>
|
|
<div id="recipe-component-list" style="display: grid; gap: 10px; margin-top: 8px;"></div>
|
|
</div>
|
|
|
|
<!-- Step 2: Shared Configuration -->
|
|
<div id="recipe-step-2" class="recipe-step-panel" style="display:none;">
|
|
<div style="display: grid; gap: 16px;">
|
|
<!-- Shared volumes -->
|
|
<div id="recipe-volumes-section" style="display:none;">
|
|
<label class="form-label-accent-sm">Shared Volumes:</label>
|
|
<div id="recipe-volume-list" style="display: grid; gap: 10px; margin-top: 8px;"></div>
|
|
</div>
|
|
|
|
<!-- Timezone -->
|
|
<div>
|
|
<label class="form-label-accent-sm">Timezone:</label>
|
|
<input type="text" id="recipe-timezone" value="UTC" placeholder="e.g. America/New_York"
|
|
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
|
</div>
|
|
|
|
<!-- Target IP -->
|
|
<div>
|
|
<label class="form-label-accent-sm">Target IP Address:</label>
|
|
<input type="text" id="recipe-ip" value="host.docker.internal" placeholder="localhost or IP"
|
|
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
|
<div class="form-hint-sm">IP where containers expose ports</div>
|
|
</div>
|
|
|
|
<!-- Tailscale -->
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="recipe-tailscale" />
|
|
<span>Restrict to Tailscale network only</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Review -->
|
|
<div id="recipe-step-3" class="recipe-step-panel" style="display:none;">
|
|
<div id="recipe-review-content" style="font-size: 0.9rem; line-height: 1.7;"></div>
|
|
</div>
|
|
|
|
<!-- Step 4: Progress -->
|
|
<div id="recipe-step-4" class="recipe-step-panel" style="display:none;">
|
|
<div id="recipe-progress-list" style="display: grid; gap: 8px;"></div>
|
|
<div id="recipe-deploy-result" style="margin-top: 16px; display:none;"></div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 20px;">
|
|
<button id="recipe-cancel">Cancel</button>
|
|
<button id="recipe-prev" class="btn-accent" style="display:none;">Back</button>
|
|
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
|
</div>
|
|
</div>
|
|
</div>`);let h=null,S=null,A=null,k=1,N=!1;const R=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),P=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(R,z);async function T(){try{const y=await fetch("/api/v1/recipes/templates"),m=await y.json();if(m.success)return h=m.templates,S=m.categories,!0;if(y.status===403)return N=!1,!1}catch(y){console.warn("Failed to fetch recipe templates:",y.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(y){await w();let m;if(N&&h?m=h:m=H(),!m||m.length===0)return;const b=document.createElement("div");b.className="app-category-header",b.innerHTML="\u{1F9EA} Recipes",b.style.borderBottomColor="#8e44ad",y.appendChild(b);const p=Array.isArray(m)?m:Object.values(m);p.sort((v,s)=>(s.popularity||0)-(v.popularity||0));for(const v of p){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const l=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${v.componentCount||v.components?.length||"?"} apps</div>`,r=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=`
|
|
${r}
|
|
<div class="app-option-icon">${escapeHtml(v.icon||"\u{1F9EA}")}</div>
|
|
<div class="app-option-name">${escapeHtml(v.name)}</div>
|
|
<div class="app-option-desc">${escapeHtml(v.description||"")}</div>
|
|
${l}
|
|
`,s.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}E(v)},y.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 E(y){A=y,k=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${y.name}`,$(),I(),R.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(y=>{const m=parseInt(y.dataset.step);y.classList.toggle("active",m===k),y.classList.toggle("completed",m<k)});for(let y=1;y<=4;y++){const m=document.getElementById(`recipe-step-${y}`);m&&(m.style.display=y===k?"":"none")}P.style.display=k>1&&k<4?"":"none",k===4?(g.style.display="none",z.textContent="Close"):k===3?(g.textContent="\u{1F680} Deploy",g.style.display="",z.textContent="Cancel"):(g.textContent="Next",g.style.display="",z.textContent="Cancel")}function I(){const y=document.getElementById("recipe-component-list");y.innerHTML="";const m=A.components||[];for(const b of m){const p=document.createElement("div");p.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const v=b.required,s=b.internal;p.innerHTML=`
|
|
<input type="checkbox" ${v?"checked disabled":"checked"} data-component-id="${b.id}"
|
|
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(b.role||b.id)}</div>
|
|
<div style="font-size: 0.78rem; color: var(--muted);">
|
|
${b.templateRef?escapeHtml(b.templateRef):"Built-in"}
|
|
${v?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
|
</div>
|
|
${b.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(b.note)}</div>`:""}
|
|
</div>
|
|
`,y.appendChild(p)}}function L(){const y=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),b=A.sharedVolumes;if(b&&Object.keys(b).length>0){y.style.display="",m.innerHTML="";for(const[p,v]of Object.entries(b)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(v.label||p)}</label>
|
|
<input type="text" data-volume-key="${p}" value="${escapeHtml(v.defaultPath||"")}"
|
|
placeholder="${escapeHtml(v.defaultPath||"/path")}"
|
|
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
|
|
<div class="form-hint-sm">${escapeHtml(v.description||"")}</div>
|
|
`,m.appendChild(s)}}else y.style.display="none"}function j(){const y=document.getElementById("recipe-review-content"),m=M(),b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};b.forEach(r=>{p[r.dataset.volumeKey]=r.value});const v=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",l=document.getElementById("recipe-tailscale").checked;y.innerHTML=`
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(A.name)}</div>
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(A.description||"")}</div>
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
<strong>Components (${m.length}):</strong>
|
|
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
|
${m.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
\u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
|
</div>`).join("")}
|
|
</div>
|
|
</div>
|
|
|
|
${Object.keys(p).length>0?`<div style="margin-bottom: 12px;">
|
|
<strong>Volumes:</strong>
|
|
${Object.entries(p).map(([r,f])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${r}: ${escapeHtml(f)}</div>`).join("")}
|
|
</div>`:""}
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted);">
|
|
Timezone: ${escapeHtml(v)} • IP: ${escapeHtml(s)} ${l?"• Tailscale only":""}
|
|
</div>
|
|
|
|
${A.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(A.network.name)}</code></div>`:""}
|
|
`}function M(){const y=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;y.forEach(p=>{p.checked&&m.add(p.dataset.componentId)});const b=A.components||[];return b.filter(p=>p.required).forEach(p=>m.add(p.id)),b.filter(p=>m.has(p.id))}async function O(){const y=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",y.innerHTML="";const b=M();for(const l of b){const r=document.createElement("div");r.id=`recipe-progress-${l.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
|
|
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(l.role||l.id)}</span>
|
|
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
|
`,y.appendChild(r)}const p=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};p.forEach(l=>{v[l.dataset.volumeKey]=l.value});const s={selectedComponents:b.map(l=>l.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:v},componentOverrides:{}};for(const l of b)x(l.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:s})})).json();if(r.success){for(const f of r.deployed||[])x(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of r.errors||[])x(f.componentId,"error",f.error);m.style.display="",m.innerHTML=`
|
|
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
|
|
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(r.message||"Deployed!")}</div>
|
|
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
|
<strong>Setup tips:</strong>
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(f=>`<li>${escapeHtml(f)}</li>`).join("")}</ul>
|
|
</div>`:""}
|
|
</div>
|
|
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
<strong>Deployment failed:</strong> ${escapeHtml(r.error||"Unknown error")}
|
|
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(l){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
<strong>Network error:</strong> ${escapeHtml(l.message)}
|
|
</div>`}}function x(y,m,b){const p=document.getElementById(`recipe-progress-${y}`);if(!p)return;const v=p.querySelector(".recipe-progress-icon"),s=p.querySelector(".recipe-progress-status");m==="deploying"?(v.textContent="\u23F3",s.style.color="var(--accent)"):m==="success"?(v.textContent="\u2705",s.style.color="var(--ok-fg)"):m==="error"&&(v.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=b}g.addEventListener("click",()=>{if(k===3){k=4,$(),O();return}k<3&&(k++,$(),k===2&&L(),k===3&&j())}),P.addEventListener("click",()=>{k>1&&k<4&&(k--,$())}),window.groupRecipeCards=function(){const y=document.querySelectorAll(".service-card[data-recipe-id]");if(y.length===0)return;const m={};y.forEach(b=>{const p=b.dataset.recipeId;m[p]||(m[p]=[]),m[p].push(b)});for(const[b,p]of Object.entries(m))p.length<2||p.forEach((v,s)=>{if(v.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let l=v.querySelector(".recipe-group-label");l||(l=document.createElement("div"),l.className="recipe-group-label",l.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;",l.textContent=b.replace(/-/g," "),v.style.position="relative",v.appendChild(l))}})},window.manageRecipe=async function(y,m){const b=`/api/v1/recipes/${y}/${m}`,p=m==="remove"?"DELETE":"POST",v=m==="remove"?`/api/v1/recipes/${y}`:b;if(!(m==="remove"&&!confirm(`Remove the entire ${y} recipe? This will delete all containers and configuration.`)))try{const l=await(await secureFetch(v,{method:p})).json();l.success?(showNotification(`Recipe ${m}: ${l.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${l.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=`
|
|
.recipe-step {
|
|
flex: 1;
|
|
text-align: center;
|
|
padding: 8px 4px;
|
|
font-size: 0.78rem;
|
|
color: var(--muted);
|
|
border-bottom: 2px solid var(--border);
|
|
transition: all 0.2s;
|
|
}
|
|
.recipe-step span {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: var(--border);
|
|
color: var(--muted);
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
margin-right: 4px;
|
|
}
|
|
.recipe-step.active {
|
|
color: var(--accent);
|
|
border-bottom-color: var(--accent);
|
|
}
|
|
.recipe-step.active span {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
}
|
|
.recipe-step.completed {
|
|
color: var(--ok-fg);
|
|
border-bottom-color: var(--ok-fg);
|
|
}
|
|
.recipe-step.completed span {
|
|
background: var(--ok-fg);
|
|
color: #fff;
|
|
}
|
|
.recipe-step-panel {
|
|
min-height: 180px;
|
|
}
|
|
`,document.head.appendChild(D),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),S=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),k=await A.json();if(A.ok&&k.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3);else throw new Error(k.error||"Reload failed")}catch(A){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),S=document.getElementById("error-log-content"),A=document.getElementById("view-error-logs"),k=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function z(){S.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const T=await(await fetch("/api/v1/error-logs")).json();T.success&&T.logs?T.logs.length===0?S.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':S.innerHTML=T.logs.map(w=>`
|
|
<div class="log-entry error">
|
|
<span class="log-timestamp">${new Date(w.timestamp).toLocaleString()}</span>
|
|
<span class="log-level">ERROR</span>
|
|
<div class="log-message">
|
|
<strong>${escapeHtml(w.context)}</strong>: ${escapeHtml(w.error)}
|
|
${w.details?`<br><small style="opacity: 0.7;">${escapeHtml(w.details)}</small>`:""}
|
|
</div>
|
|
</div>
|
|
`).join(""):S.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){S.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function P(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}A?.addEventListener("click",()=>{h.classList.add("show"),z()}),k?.addEventListener("click",z),N?.addEventListener("click",P),wireModal(h,R)})(),(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>
|
|
<h3 style="margin: 0;">\u{1F4DC} Container Logs</h3>
|
|
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">View and stream Docker container logs</p>
|
|
</div>
|
|
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center;">
|
|
<select id="cl-container-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 200px;">
|
|
<option value="">Select a container...</option>
|
|
</select>
|
|
<input type="text" id="cl-log-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 150px;" />
|
|
<select id="cl-log-tail" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
|
|
<option value="50">Last 50 lines</option>
|
|
<option value="100" selected>Last 100 lines</option>
|
|
<option value="500">Last 500 lines</option>
|
|
<option value="1000">Last 1000 lines</option>
|
|
<option value="all">All logs</option>
|
|
</select>
|
|
<button id="cl-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u{1F504} Refresh</button>
|
|
<button id="cl-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u25B6 Stream</button>
|
|
<button id="cl-download" style="padding: 6px 14px; font-size: 0.85rem;">\u{1F4BE} Download</button>
|
|
<button id="cl-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">\u2715</button>
|
|
<button id="cl-close" class="close-btn" style="padding: 6px 10px;">\u2715</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="cl-container-info" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem;">
|
|
<span><strong>Image:</strong> <span id="cl-image">-</span></span>
|
|
<span><strong>Status:</strong> <span id="cl-status">-</span></span>
|
|
<span><strong>Created:</strong> <span id="cl-created">-</span></span>
|
|
</div>
|
|
|
|
<div id="cl-log-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
|
|
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>
|
|
</div>
|
|
|
|
<div id="cl-stream-status" style="display: none; padding: 8px 12px; background: var(--ok-bg, #1a3a1a); color: var(--ok-fg, #4ade80); border-radius: 6px; margin-top: 8px; font-size: 0.82rem;">
|
|
<span id="cl-stream-indicator">\u{1F534}</span> <span id="cl-stream-text">Disconnected</span>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
|
|
<span id="cl-line-count">0 lines</span>
|
|
<span>|</span>
|
|
<span id="cl-filter-count">0 filtered</span>
|
|
</div>
|
|
<button id="cl-close-btn" class="btn-secondary">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("container-logs-modal"),S=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),k=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),P=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),E=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),I=document.getElementById("cl-line-count"),L=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),M=document.getElementById("cl-status"),O=document.getElementById("cl-created");let x=null,D=[],y=[],m=null,b=!1,p=null;function v(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function s(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function l(i,c){const C=i.stream==="stderr"?"log-stderr":"log-stdout",B=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
<div class="log-entry ${C}" data-index="${c}" 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;">${c+1}</span>
|
|
<span class="log-stream-icon" style="color: ${i.stream==="stderr"?"#f59e0b":"#22c55e"};">${B}</span>
|
|
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(i.text)}</span>
|
|
</div>
|
|
`}function r(i,c=""){if(!i||i.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',I.textContent="0 lines",L.textContent="0 filtered";return}if(D=i,y=c?i.filter(C=>C.text&&C.text.toLowerCase().includes(c.toLowerCase())):i,I.textContent=`${i.length} lines`,L.textContent=c?`${y.length} of ${i.length} shown`:`${i.length} shown`,y.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(c)}"</div>`;return}A.innerHTML=y.map((C,B)=>l(C,B)).join(""),A.scrollTop=A.scrollHeight}async function f(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],C=S.value;S.innerHTML='<option value="">Select a container...</option>',c.forEach(B=>{const F=document.createElement("option");F.value=B.id,F.textContent=`${B.name} (${B.image.split(":")[0]}) - ${B.status}`,F.dataset.name=B.name,F.dataset.image=B.image,F.dataset.status=B.status,F.dataset.created=B.created,S.appendChild(F)}),C&&S.querySelector(`option[value="${C}"]`)&&(S.value=C,u(C))}catch(i){console.error("Failed to load containers:",i)}}function u(i){const c=S.querySelector(`option[value="${i}"]`);c&&(j.textContent=c.dataset.image||"-",M.textContent=c.dataset.status||"-",M.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",O.textContent=v(c.dataset.created))}async function t(){const i=S.value;if(!i){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}o(),x=i,u(i);const c=N.value,C=k.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const B=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,F=await getJSON(B);F.logs&&F.logs.length>0?r(F.logs,C):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',I.textContent="0 lines",L.textContent="0 filtered")}catch(B){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(B.message)}</div>`}}function e(){const i=S.value;if(!i)return;o(),b=!0,z.textContent="\u23F9 Stop",H.style.display="flex",E.textContent="\u{1F7E2}",$.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;m=new EventSource(c),m.onopen=()=>{E.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},m.onmessage=C=>{try{const B=JSON.parse(C.data);if(B.error){E.textContent="\u{1F534}",$.textContent=`Error: ${B.error}`;return}D.push(B),y.push(B),I.textContent=`${D.length} lines`,L.textContent=`${y.length} shown`;const F=k.value.trim();if(!F||B.text&&B.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=l(B,y.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",A.appendChild(U),A.scrollTop=A.scrollHeight}}catch(B){console.error("Error parsing log:",B)}},m.onerror=()=>{E.textContent="\u{1F534}",$.textContent="Disconnected",b=!1,z.textContent="\u25B6 Stream"},h._eventSource=m}function o(){m&&(m.close(),m=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),b=!1,z.textContent="\u25B6 Stream",H.style.display="none"}function a(){if(!D||D.length===0){showNotification("No logs to download","error");return}const i=S.querySelector(`option[value="${x}"]`)?.dataset.name||x,c=new Date().toISOString().replace(/[:.]/g,"-"),C=`${i}-logs-${c}.txt`,B=D.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(`
|
|
`),F=new Blob([B],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=C,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${D.length} log lines`,"success")}S?.addEventListener("change",()=>{t()}),N?.addEventListener("change",()=>{t()}),R?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{b?o():e()}),P?.addEventListener("click",()=>{a()}),g?.addEventListener("click",()=>{k.value="",r(D,"")}),k?.addEventListener("input",()=>{clearTimeout(p),p=setTimeout(()=>{r(D,k.value.trim())},300)}),k?.addEventListener("keydown",i=>{i.key==="Escape"&&(k.value="",r(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),f()});function n(){o(),h.classList.remove("show")}T?.addEventListener("click",n),w?.addEventListener("click",n),document.addEventListener("keydown",i=>{i.key==="Escape"&&h.classList.contains("show")&&n()}),h.addEventListener("click",i=>{i.target===h&&n()}),window.openContainerLogsModal=function(i,c){h.classList.add("show"),f().then(()=>{const C=Array.from(S.options).find(B=>B.value===i||B.dataset.name===c);C?(S.value=C.value,u(C.value),t()):i?(x=i,j.textContent=c||i,M.textContent="-",O.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(){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">
|
|
Create and manage Docker container checkpoints for instant state recovery.
|
|
</p>
|
|
|
|
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
|
|
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
|
|
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
|
<option value="">-- Select a container --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
|
|
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
|
|
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
|
|
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
|
|
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-tabs" style="margin-bottom: 12px;">
|
|
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
|
|
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
|
|
</div>
|
|
|
|
<div id="snapshot-create" class="panel-section active">
|
|
<div style="margin-bottom: 12px;">
|
|
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
|
|
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
|
|
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
|
|
</div>
|
|
<div style="margin-bottom: 12px;">
|
|
<label class="checkbox-label" style="font-size: 0.85rem;">
|
|
<input type="checkbox" id="snapshot-leave-running" checked />
|
|
Leave container running after checkpoint (resume without restart)
|
|
</label>
|
|
</div>
|
|
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">\u{1F4BE} Create Snapshot</button>
|
|
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
|
|
</div>
|
|
|
|
<div id="snapshot-list" class="panel-section" style="display: none;">
|
|
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
|
|
<div class="panel-empty"><span class="empty-icon">\u{1F4BE}</span>Select a container to view its snapshots</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="snapshot-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("snapshot-modal"),S=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),k=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let P=null;async function g(){try{const I=await(await fetch("/api/v1/containers")).json();if(!I.success||!I.containers)return;k.innerHTML='<option value="">-- Select a container --</option>';for(const L of I.containers){const j=document.createElement("option");j.value=L.id,j.textContent=`${L.name||L.id} (${L.image||"unknown"})`,j.dataset.name=L.name,j.dataset.image=L.image,j.dataset.status=L.status,j.dataset.created=L.created,k.appendChild(j)}}catch($){console.error("Failed to load containers:",$)}}function T($){if(!$||!$.value){N.style.display="none",P=null;return}P=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),N.style.display=""}async function w(){if(!P){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const I=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(P)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:I})})).json();j.success?(z.textContent=`\u2713 Snapshot "${$}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(L){z.textContent=`\u2717 Error: ${L.message}`,z.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function H(){h.classList.add("show"),g()}function E(){h.classList.remove("show"),N.style.display="none",P=null,k.selectedIndex=0}S?.addEventListener("click",H),A?.addEventListener("click",E),wireModal(h,A),k?.addEventListener("change",$=>{const I=k.options[k.selectedIndex];T(I)}),R?.addEventListener("click",w),h?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(I=>I.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(I=>I.classList.remove("active")),$.classList.add("active"),h.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;">
|
|
Auto-discover and connect your entire media stack.
|
|
</p>
|
|
|
|
<!-- Phase 1: Detection -->
|
|
<div id="smart-phase-detect">
|
|
<div style="text-align: center; padding: 20px;">
|
|
<span class="brand-spinner" style="margin-bottom: 12px; display: inline-block;"></span>
|
|
<div style="color: var(--muted); font-size: 0.9rem;">Scanning for services...</div>
|
|
</div>
|
|
<div id="smart-detect-results" style="display: none;"></div>
|
|
</div>
|
|
|
|
<!-- Phase 2: Credential Input (only for services needing keys) -->
|
|
<div id="smart-phase-credentials" style="display: none;">
|
|
<h4 class="heading-accent-section">Enter Missing API Keys</h4>
|
|
<div id="smart-credential-inputs"></div>
|
|
|
|
<!-- Connection Options -->
|
|
<div style="margin-top: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<h4 style="margin: 0 0 10px; font-size: 0.85rem; color: var(--accent);">Connection Options</h4>
|
|
<label class="option-label">
|
|
<input type="checkbox" id="smart-opt-seerr" checked class="checkbox-sm" />
|
|
Configure Seerr with Radarr + Sonarr
|
|
</label>
|
|
<label class="option-label">
|
|
<input type="checkbox" id="smart-opt-plex" checked class="checkbox-sm" />
|
|
Connect Plex to Seerr
|
|
</label>
|
|
<label class="option-label">
|
|
<input type="checkbox" id="smart-opt-prowlarr" checked class="checkbox-sm" />
|
|
Connect Prowlarr to Radarr + Sonarr
|
|
</label>
|
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.85rem;">
|
|
<input type="checkbox" id="smart-opt-save" checked class="checkbox-sm" />
|
|
Save API keys for one-click reconnect
|
|
</label>
|
|
</div>
|
|
|
|
<button id="smart-connect-btn" style="width: 100%; margin-top: 16px; padding: 12px; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none; color: white; font-weight: 600; font-size: 1rem; border-radius: 8px; cursor: pointer;">
|
|
Smart Connect
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Phase 3: Connection Progress -->
|
|
<div id="smart-phase-progress" style="display: none;">
|
|
<h4 class="heading-accent-section">Connecting Everything...</h4>
|
|
<div id="smart-progress-steps" style="display: flex; flex-direction: column; gap: 6px;"></div>
|
|
</div>
|
|
|
|
<!-- Phase 4: Results -->
|
|
<div id="smart-phase-results" style="display: none;">
|
|
<div id="smart-results-content"></div>
|
|
<div id="smart-plex-libraries" style="display: none; margin-top: 12px;"></div>
|
|
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
|
<button id="smart-retry-btn" style="display: none; flex: 1; padding: 10px; background: #f39c12; border: none; color: white; font-weight: 500; border-radius: 8px; cursor: pointer;">
|
|
Retry Failed Steps
|
|
</button>
|
|
<a id="smart-open-seerr" href="#" target="_blank" rel="noopener noreferrer"
|
|
style="flex: 1; padding: 10px; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; text-decoration: none; text-align: center;">
|
|
Open Seerr
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Help Text -->
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 6px;">
|
|
<strong>Where to find API keys:</strong><br>
|
|
Radarr/Sonarr/Prowlarr: Settings -> General -> Security -> API Key
|
|
</div>
|
|
|
|
<!-- Close Button -->
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="arr-setup-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("arr-setup-modal"),S=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),k=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),P=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),E=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let I=null;const L={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(p){N.style.display=p==="detect"?"block":"none",R.style.display=p==="credentials"?"block":"none",z.style.display=p==="progress"?"block":"none",P.style.display=p==="results"?"block":"none"}function O(p){const v={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=v[p]||v.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 x(){M("detect"),g.style.display="none";try{if(I=await(await fetch("/api/v1/arr/smart-detect")).json(),!I.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(I.error)}</div>`,g.style.display="block";return}let v='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[l,r]of Object.entries(I.services)){const f=L[l]||"\u{1F4E6}",u=j[l]||l,t=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",e=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",o=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";v+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<span style="font-size: 1.1rem;">${f}</span>
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
|
|
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
|
${t} ${e} ${o}
|
|
</div>
|
|
</div>
|
|
${O(r.status)}
|
|
</div>`}v+="</div>";const s=I.summary;v+=`<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>`,g.innerHTML=v,g.style.display="block",D(I),setTimeout(()=>{M("credentials")},800)}catch(p){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(p.message)}</div>`,g.style.display="block"}}function D(p){let v="";const s=p.services,l=["radarr","sonarr","prowlarr"];for(const u of l){const t=s[u];if(!t||t.status==="not_found"&&!t.url)continue;const e=L[u],o=j[u],a=t.status==="connected";v+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"var(--ok-fg)":"var(--border)"};">
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
|
<span style="font-size: 1.1rem;">${e}</span>
|
|
<span style="font-weight: 500;">${o}</span>
|
|
<span id="smart-${u}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
${a?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
|
</span>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
|
<input type="text" id="smart-${u}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${u}/"
|
|
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
|
</div>
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
|
<input type="password" id="smart-${u}-key" placeholder="${a?"(saved)":"Paste API key"}"
|
|
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
|
</div>
|
|
</div>
|
|
<button onclick="smartTestConnection('${u}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
</div>`}const r=s.plex;if(r){const u=r.status==="connected";v+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
|
|
<span style="font-weight: 500;">Plex</span>
|
|
${O(r.status)}
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
|
|
</div>
|
|
</div>`}const f=s.seerr;if(f){const u=f.status==="connected";let t="";if(f.configuredServices){const e=f.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} ·
|
|
${e.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
|
${e.plex?"✓ Plex":"✗ Plex"}
|
|
</div>`}v+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"};">
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
|
|
<span style="font-weight: 500;">Seerr</span>
|
|
${O(f.status)}
|
|
</div>
|
|
${t}
|
|
</div>`}T.innerHTML=v}window.smartTestConnection=async function(p){const v=document.getElementById(`smart-${p}-url`),s=document.getElementById(`smart-${p}-key`),l=document.getElementById(`smart-${p}-status`),r=v?.value.trim(),f=s?.value.trim();if(!r||!f){l.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}l.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:p,url:r,apiKey:f})})).json();t.success?l.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:l.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(t.error)}</span>`}catch(u){l.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(u.message)}</span>`}};async function y(){M("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const p={};for(const s of["radarr","sonarr","prowlarr"]){const l=document.getElementById(`smart-${s}-url`)?.value.trim(),r=document.getElementById(`smart-${s}-key`)?.value.trim();r&&l?p[s]={apiKey:r,url:l}:r&&(p[s]={apiKey:r})}const v={services:Object.keys(p).length>0?p:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const l=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)})).json();let r="";for(const f of l.steps||[]){const u=f.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',t=f.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
${u}
|
|
<span>${escapeHtml(f.step)}</span>
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(f.details||"")}</span>
|
|
</div>`}w.innerHTML=r,setTimeout(()=>m(l),500)}catch(s){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function m(p){M("results");const v=p.summary||{},s=v.failed===0&&v.succeeded>0,l=s?"var(--ok-fg)":"#f39c12",r=s?"✓":"⚠",f=s?"All Connected!":`${escapeHtml(String(v.succeeded))}/${escapeHtml(String(v.totalSteps))} Steps Succeeded`;let u=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${l} 12%, transparent); border-radius: 10px; border: 1px solid ${l}; margin-bottom: 12px;">
|
|
<div style="font-size: 1.5rem; color: ${l};">${r}</div>
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${l};">${f}</div>
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(v.succeeded))} succeeded, ${escapeHtml(String(v.failed))} failed</div>
|
|
</div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of p.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';u+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
|
|
</div>`}u+="</div>",H.innerHTML=u,$.style.display=v.failed>0?"block":"none",p.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&b()}async function b(){try{const v=await(await fetch("/api/v1/plex/libraries")).json();if(v.success&&v.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(v.serverName)} Libraries</h4>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const l of v.libraries){const r=l.type==="movie"?"\u{1F3AC}":l.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
${r} <strong>${escapeHtml(l.title)}</strong>
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(l.count))} items</span>
|
|
</div>`}s+="</div></div>",E.innerHTML=s,E.style.display="block"}}catch{}}S?.addEventListener("click",()=>{h.classList.add("show"),E.style.display="none",x()}),wireModal(h,A),k?.addEventListener("click",y),$?.addEventListener("click",y)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
|
<h3>\u{1F514} Notification Settings</h3>
|
|
|
|
<!-- Master Toggle -->
|
|
<div class="accent-info-box">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
<input type="checkbox" id="notifications-enabled" />
|
|
<div>
|
|
<span style="font-weight: 600; color: var(--accent);">Enable Notifications</span>
|
|
<div class="text-tiny-muted">Receive alerts when containers go up/down</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Providers Section -->
|
|
<h4 class="section-heading">Notification Providers</h4>
|
|
|
|
<!-- Discord -->
|
|
<div class="notification-provider provider-card">
|
|
<div class="provider-header">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="discord-enabled" />
|
|
<span class="fw-500">Discord</span>
|
|
</label>
|
|
<button id="discord-test" class="test-btn btn-xs">Test</button>
|
|
</div>
|
|
<div id="discord-config" style="display: none;">
|
|
<label class="field-label-sm">Webhook URL:</label>
|
|
<input type="text" id="discord-webhook" placeholder="https://discord.com/api/v1/webhooks/..." style="width: 100%;" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Telegram -->
|
|
<div class="notification-provider provider-card">
|
|
<div class="provider-header">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="telegram-enabled" />
|
|
<span class="fw-500">Telegram</span>
|
|
</label>
|
|
<button id="telegram-test" class="test-btn btn-xs">Test</button>
|
|
</div>
|
|
<div id="telegram-config" style="display: none;">
|
|
<div class="grid-2col">
|
|
<div>
|
|
<label class="field-label-sm">Bot Token:</label>
|
|
<input type="text" id="telegram-bot-token" placeholder="123456:ABC..." />
|
|
</div>
|
|
<div>
|
|
<label class="field-label-sm">Chat ID:</label>
|
|
<input type="text" id="telegram-chat-id" placeholder="-1001234567890" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ntfy.sh -->
|
|
<div class="notification-provider provider-card">
|
|
<div class="provider-header">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="ntfy-enabled" />
|
|
<span class="fw-500">ntfy.sh</span>
|
|
</label>
|
|
<button id="ntfy-test" class="test-btn btn-xs">Test</button>
|
|
</div>
|
|
<div id="ntfy-config" style="display: none;">
|
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
|
<div>
|
|
<label class="field-label-sm">Server URL:</label>
|
|
<input type="text" id="ntfy-server" placeholder="https://ntfy.sh" value="https://ntfy.sh" />
|
|
</div>
|
|
<div>
|
|
<label class="field-label-sm">Topic:</label>
|
|
<input type="text" id="ntfy-topic" placeholder="dashcaddy-alerts" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Email -->
|
|
<div class="notification-provider provider-card">
|
|
<div class="provider-header">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="email-enabled" />
|
|
<span class="fw-500">Email (SMTP)</span>
|
|
</label>
|
|
<button id="email-test" class="test-btn btn-xs">Test</button>
|
|
</div>
|
|
<div id="email-config" style="display: none;">
|
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">
|
|
<div>
|
|
<label class="field-label-sm">SMTP Host:</label>
|
|
<input type="text" id="email-host" placeholder="smtp.gmail.com" />
|
|
</div>
|
|
<div>
|
|
<label class="field-label-sm">Port:</label>
|
|
<input type="number" id="email-port" value="587" placeholder="587" />
|
|
</div>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
|
<div>
|
|
<label class="field-label-sm">Username:</label>
|
|
<input type="text" id="email-user" placeholder="user@gmail.com" />
|
|
</div>
|
|
<div>
|
|
<label class="field-label-sm">Password:</label>
|
|
<input type="password" id="email-pass" placeholder="app password" />
|
|
</div>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
|
<div>
|
|
<label class="field-label-sm">From:</label>
|
|
<input type="text" id="email-from" placeholder="DashCaddy <noreply@example.com>" />
|
|
</div>
|
|
<div>
|
|
<label class="field-label-sm">To:</label>
|
|
<input type="text" id="email-to" placeholder="admin@example.com" />
|
|
</div>
|
|
</div>
|
|
<label style="display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 0.8rem;">
|
|
<input type="checkbox" id="email-secure" /> Use TLS (port 465)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Health Check Settings -->
|
|
<h4 class="section-heading">Health Monitoring</h4>
|
|
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
|
|
<input type="checkbox" id="health-check-enabled" />
|
|
<div>
|
|
<span class="fw-500">Enable Health Monitoring</span>
|
|
<div class="text-tiny-muted">Periodically check container status</div>
|
|
</div>
|
|
</label>
|
|
<div id="health-check-config" style="display: flex; align-items: center; gap: 10px;">
|
|
<label class="field-label-sm">Check interval:</label>
|
|
<select id="health-check-interval" style="width: auto;">
|
|
<option value="1">1 minute</option>
|
|
<option value="5" selected>5 minutes</option>
|
|
<option value="15">15 minutes</option>
|
|
<option value="30">30 minutes</option>
|
|
<option value="60">1 hour</option>
|
|
</select>
|
|
<button id="health-check-now" style="padding: 4px 10px; font-size: 0.75rem; margin-left: auto;">Check Now</button>
|
|
</div>
|
|
<div id="health-check-status" style="font-size: 0.75rem; color: var(--muted); margin-top: 8px;">
|
|
Last check: Never
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Types -->
|
|
<h4 class="section-heading">Events to Notify</h4>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<label class="checkbox-label-sm">
|
|
<input type="checkbox" id="event-container-down" checked /> Container Down
|
|
</label>
|
|
<label class="checkbox-label-sm">
|
|
<input type="checkbox" id="event-container-up" checked /> Container Recovered
|
|
</label>
|
|
<label class="checkbox-label-sm">
|
|
<input type="checkbox" id="event-deploy-success" checked /> Deployment Success
|
|
</label>
|
|
<label class="checkbox-label-sm">
|
|
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
|
|
</label>
|
|
<label class="checkbox-label-sm" style="grid-column: 1 / -1;">
|
|
<input type="checkbox" id="event-resource-alert" checked /> Resource Alerts
|
|
</label>
|
|
</div>
|
|
|
|
<!-- History -->
|
|
<h4 class="section-heading">Notification History</h4>
|
|
<div id="notification-history" style="max-height: 150px; overflow-y: auto; padding: 8px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); font-size: 0.8rem;">
|
|
<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>
|
|
</div>
|
|
<div id="last-notification-sent" style="font-size: 0.75rem; color: var(--muted); text-align: center; margin-top: 6px;"></div>
|
|
|
|
<!-- Buttons -->
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="notifications-send-test" class="btn-secondary" style="margin-right: auto;">Send Test</button>
|
|
<button id="notifications-cancel">Cancel</button>
|
|
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const S=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),k=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(E=>{const $=document.getElementById(`${E}-enabled`),I=document.getElementById(`${E}-config`);$?.addEventListener("change",()=>{I.style.display=$.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");R?.addEventListener("change",()=>{z.style.opacity=R.checked?"1":"0.5"});async function P(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const I=$.config;document.getElementById("notifications-enabled").checked=I.enabled,document.getElementById("discord-enabled").checked=I.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=I.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=I.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=I.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=I.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=I.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=I.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=I.providers?.email?.enabled?"block":"none",I.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=I.providers.ntfy.serverUrl),I.providers?.email?.host&&(document.getElementById("email-host").value=I.providers.email.host),I.providers?.email?.from&&(document.getElementById("email-from").value=I.providers.email.from),document.getElementById("health-check-enabled").checked=I.healthCheck?.enabled||!1,I.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=I.healthCheck.intervalMinutes),I.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(I.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=I.events?.containerDown!==!1,document.getElementById("event-container-up").checked=I.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=I.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=I.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=I.events?.resourceAlert!==!1}}catch(E){h.logError("[Notifications] Load Config",E,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),I=document.getElementById("notification-history");$.success&&$.history?.length>0?I.innerHTML=$.history.map(L=>{const j=new Date(L.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)"}[L.type]||"var(--muted)"}">${L.type==="success"?"\u2713":L.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(L.title)}</div>
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div>
|
|
</div>
|
|
</div>
|
|
`}).join(""):I.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(E){h.logError("[Notifications] Load History",E,{function:"loadHistory"})}}async function T(){try{const E={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}},I=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();I.success?(showNotification("Notification settings saved","success",3e3),S.classList.remove("show")):showNotification(`Failed to save: ${I.error}`,"error",3e3)}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}}async function w(E){try{const I=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:E})})).json();I.success?showNotification(`Test ${E} notification sent!`,"success",3e3):showNotification(`Test failed: ${I.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=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(E){showNotification(`Error: ${E.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{S.classList.add("show"),P(),g()}),k?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const E=document.getElementById("notifications-send-test"),$=E.textContent;E.textContent="Sending...",E.disabled=!0;try{const L=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();L.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${L.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(I){showNotification(`Error: ${I.message}`,"error",3e3)}finally{E.textContent=$,E.disabled=!1}});async function H(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){const I=document.getElementById("last-notification-sent");I&&(I.textContent=`Last sent: ${new Date($.lastSent).toLocaleString()}`)}}catch{}}wireModal(S,N)})(),(function(){document.addEventListener("click",h=>{const S=h.target.closest(".panel-tab");if(!S)return;const A=S.dataset.panel;if(!A)return;const k=S.closest(".panel-tabs"),N=k.closest(".weather-modal-content");k.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),S.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const R=N.querySelector("#"+A);R&&R.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function S(){for(var e={},o=0;o<h.length;o++){var a=h[o],d=safeGet(a);d!=null&&(e[a]=d)}try{for(var n=0;n<localStorage.length;n++){var i=localStorage.key(n);/^widget-.+-enabled$/.test(i)&&(e[i]=localStorage.getItem(i))}}catch{}return e}function A(e){if(!e||typeof e!="object")return 0;var o=0;for(var a in e)e.hasOwnProperty(a)&&(safeSet(a,e[a]),o++);return o}function k(e){return e.version&&!e.files&&e.services}function N(e){var o={};e.customServices&&(o["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(o["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(o["weather-zip"]=e.weatherZip),e.theme&&(o.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(o["user-themes"]=JSON.stringify(e.userThemes)),A(o),e.services&&Array.isArray(e.services)&&secureFetch("/api/v1/services",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e.services)}).catch(function(){}),e.userThemes&&Object.keys(e.userThemes).forEach(function(a){var d=e.userThemes[a],n={};(window.THEME_PROPS||[]).forEach(function(i){d[i]&&(n[i]=d[i])}),secureFetch("/api/v1/themes/"+a,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:d.name||a,colors:n})}).catch(function(){})})}injectModal("backup-modal",`<div id="backup-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
|
<h3>\u{1F4BE} Backup & Restore</h3>
|
|
<p class="modal-subtitle">
|
|
Full backup of your entire DashCaddy setup \u2014 server config, credentials, themes, and browser preferences in one file.
|
|
</p>
|
|
|
|
<!-- Tab bar -->
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-panel="backup-manual">Manual</button>
|
|
<button class="panel-tab" data-panel="backup-schedules-tab">Schedules</button>
|
|
<button class="panel-tab" data-panel="backup-disk-tab">Backups on Disk</button>
|
|
<button class="panel-tab" data-panel="backup-pointintime-tab">Point-in-Time</button>
|
|
<button class="panel-tab" data-panel="backup-history-tab">History</button>
|
|
</div>
|
|
|
|
<!-- Tab: Manual -->
|
|
<div id="backup-manual" class="panel-section active">
|
|
<!-- Export Section -->
|
|
<div style="margin-bottom: 20px; padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #27ae60 15%, transparent), color-mix(in srgb, #2ecc71 10%, transparent)); border-radius: 10px; border: 1px solid #27ae60;">
|
|
<h4 style="margin: 0 0 8px; color: #2ecc71; font-size: 0.95rem;">\u{1F4E4} Export Backup</h4>
|
|
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
|
Downloads everything \u2014 services, Caddyfile, credentials, encryption key, themes, and all browser preferences.
|
|
</p>
|
|
<button id="backup-export-btn" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
|
\u2B07\uFE0F Download Full Backup
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Import Section -->
|
|
<div style="padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #3498db 15%, transparent), color-mix(in srgb, #2980b9 10%, transparent)); border-radius: 10px; border: 1px solid #3498db;">
|
|
<h4 style="margin: 0 0 8px; color: #3498db; font-size: 0.95rem;">\u{1F4E5} Restore Backup</h4>
|
|
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
|
Upload a backup file to restore your entire configuration \u2014 drag and drop ready.
|
|
</p>
|
|
<input type="file" id="backup-file-input" accept=".json" style="display: none;" />
|
|
<button id="backup-select-file" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
|
\u{1F4C1} Select Backup File
|
|
</button>
|
|
<div id="backup-file-name" style="display: none; margin-top: 8px; padding: 8px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;"></div>
|
|
</div>
|
|
|
|
<!-- Preview Section (shown after file selected) -->
|
|
<div id="backup-preview" style="display: none; margin-top: 16px; padding: 16px; background: var(--card-base); border-radius: 10px; border: 1px solid var(--border);">
|
|
<h4 style="margin: 0 0 12px; font-size: 0.9rem;">\u{1F4CB} Backup Contents</h4>
|
|
<div id="backup-preview-content" style="font-size: 0.85rem;"></div>
|
|
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<label class="checkbox-label" style="font-size: 0.85rem;">
|
|
<input type="checkbox" id="backup-reload-caddy" checked />
|
|
Reload Caddy after restore
|
|
</label>
|
|
</div>
|
|
<button id="backup-do-restore-btn" style="width: 100%; margin-top: 12px; padding: 10px; background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
|
\u26A1 Restore Everything
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Result Message -->
|
|
<div id="backup-result" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
|
</div>
|
|
|
|
<!-- Tab: Schedules (Premium) -->
|
|
<div id="backup-schedules-tab" class="panel-section">
|
|
<div id="backup-schedules-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u23F0</span>
|
|
<span class="brand-spinner"></span> Loading schedules...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Backups on Disk -->
|
|
<div id="backup-disk-tab" class="panel-section">
|
|
<div id="backup-disk-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u{1F4BE}</span>
|
|
<span class="brand-spinner"></span> Loading backup files...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Point-in-Time Restore -->
|
|
<div id="backup-pointintime-tab" class="panel-section">
|
|
<div id="pointintime-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u23EA</span>
|
|
<span class="brand-spinner"></span> Loading...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Backup History -->
|
|
<div id="backup-history-tab" class="panel-section">
|
|
<div id="backup-history-container" style="max-height: 400px; overflow-y: auto;">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u{1F4CB}</span>
|
|
<span class="brand-spinner"></span> Loading backup history...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Close Button -->
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="backup-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);var R=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),P=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),E=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),I=document.getElementById("backup-do-restore-btn"),L=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),M=document.getElementById("backup-history-container"),O=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),D=null;z?.addEventListener("click",function(){R.classList.add("show"),L&&(L.style.display="none"),E&&(E.style.display="none"),H&&(H.style.display="none"),D=null}),wireModal(R,P),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),o=await e.json();o.browserState=S();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),d=URL.createObjectURL(a),n=document.createElement("a");n.href=d,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(d);var i=Object.keys(o.browserState).length,c=o.themes?Object.keys(o.themes).length:0;L.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),L.style.display="block",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)"}catch(C){L.innerHTML="\u274C Export failed: "+escapeHtml(C.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var o=e.target.files[0];if(o){H.textContent="\u{1F4C4} "+o.name,H.style.display="block",L.style.display="none";try{var a=await o.text(),d=JSON.parse(a);if(k(d)){D=d;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(d.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',d.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+d.services.length+" services</span>"),d.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+d.customApps.length+" custom apps</span>"),d.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(d.theme)+"</span>"),d.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(d.userThemes).length+" custom themes</span>"),n+="</div>",$.innerHTML=n,E.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)}),c=await i.json();if(c.success){D=d;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(d.exportedAt).toLocaleString()+" (v"+escapeHtml(d.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 C in c.preview.files){var B=c.preview.files[C],F=B.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(B.description)+"</span>"}n+="</div>",c.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+c.preview.serviceCount+" services</div>"),c.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+c.preview.themeCount+" custom themes</div>"),c.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 '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=n,E.style.display="block"}else L.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),L.style.display="block",L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12",E.style.display="none"}catch(q){L.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)",E.style.display="none"}}}),I?.addEventListener("click",async function(){if(D&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){I.disabled=!0,I.innerHTML='<span class="brand-spinner"></span> Restoring...';try{if(k(D)){N(D),L.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)",L.style.display="block",setTimeout(function(){location.reload()},2e3),I.disabled=!1,I.innerHTML="\u26A1 Restore Everything";return}var e=document.getElementById("backup-reload-caddy")?.checked??!0,o=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:D,options:{reloadCaddy:e}})}),a=await o.json(),d=0;if(D.browserState&&(d=A(D.browserState)),a.success){var n="\u2705 "+a.message;d>0&&(n+='<br><small style="color: var(--muted);">'+d+" browser settings restored</small>"),a.results.caddyReloaded&&(n+='<br><small style="color: var(--muted);">Caddy configuration reloaded</small>'),L.innerHTML=n,L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else L.innerHTML="\u26A0\uFE0F "+escapeHtml(a.message),d>0&&(L.innerHTML+='<br><small style="color: var(--muted);">'+d+" browser settings were restored</small>"),a.results?.errors?.length>0&&(L.innerHTML+="<br><small>"+a.results.errors.map(function(i){return escapeHtml(i.file)+": "+escapeHtml(i.error)}).join(", ")+"</small>"),L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12";L.style.display="block"}catch(i){L.innerHTML="\u274C Restore failed: "+escapeHtml(i.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}I.disabled=!1,I.innerHTML="\u26A1 Restore Everything"}});async function y(){if(j){j.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';try{var e=await fetch("/api/v1/backups/schedule"),o=await e.json();if(o.premiumRequired){j.innerHTML=`<div class="panel-empty" style="padding: 24px; text-align: center;"><div style="font-size: 2rem; margin-bottom: 12px;">\u2B50</div><div style="font-weight: 600; margin-bottom: 8px;">Premium Feature</div><div style="font-size: 0.85rem; color: var(--muted);">Auto-backup scheduling requires a DashCaddy Premium subscription.</div><button onclick="showNotification('Upgrade to Premium to enable auto-backups!', 'info'); scrollToSection('license');" style="margin-top: 16px; padding: 8px 20px; background: linear-gradient(135deg, #f39c12, #e67e22); border: none; color: white; border-radius: 8px; cursor: pointer; font-weight: 600;">Upgrade to Premium</button></div>`;return}if(!o.success)throw new Error(o.error||"Failed to load schedules");var a=o.schedules||[];if(a.length===0){j.innerHTML='<div class="panel-empty"><span class="empty-icon">\u23F0</span><div>No backup schedules configured</div><div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Select apps below to enable auto-backup</div></div>';return}for(var d='<div style="display: flex; flex-direction: column; gap: 8px;">',n=0;n<a.length;n++){var i=a[n],c=i.nextRun?new Date(i.nextRun).toLocaleString():"Not scheduled",C=i.lastRun?new Date(i.lastRun).toLocaleString():"Never";d+='<div style="padding: 12px 14px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"><div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="font-weight: 600; font-size: 0.9rem;">'+escapeHtml(i.appId)+'</div> <label class="toggle-switch" style="display: flex; align-items: center; gap: 6px;"> <input type="checkbox" class="schedule-toggle" data-appid="'+escapeHtml(i.appId)+'"'+(i.enabled?" checked":"")+' /> <span style="font-size: 0.75rem; color: var(--muted);">'+(i.enabled?"ON":"OFF")+'</span> </label></div><div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.8rem; margin-bottom: 10px;"> <div><span style="color: var(--muted);">Schedule:</span> <select class="schedule-select" data-appid="'+escapeHtml(i.appId)+'" style="background: var(--base); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px;"> <option value="hourly"'+(i.schedule==="hourly"?" selected":"")+'>Hourly</option> <option value="daily"'+(i.schedule==="daily"?" selected":"")+'>Daily</option> <option value="weekly"'+(i.schedule==="weekly"?" selected":"")+'>Weekly</option> <option value="monthly"'+(i.schedule==="monthly"?" selected":"")+'>Monthly</option> </select></div> <div><span style="color: var(--muted);">Keep last:</span> <input type="number" class="retention-input" data-appid="'+escapeHtml(i.appId)+'" value="'+(i.retention?.keep||7)+'" min="1" max="100" style="width: 50px; background: var(--base); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px;" /></div></div><div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 10px;"> <div>Next run: '+escapeHtml(c)+"</div> <div>Last run: "+escapeHtml(C)+'</div></div><div style="display: flex; gap: 6px;"> <button class="schedule-run-now" data-appid="'+escapeHtml(i.appId)+'" style="padding: 5px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--ok-fg) 20%, transparent); border: 1px solid var(--ok-fg); color: var(--ok-fg); border-radius: 6px; cursor: pointer;">\u25B6\uFE0F Run Now</button> <button class="schedule-delete" data-appid="'+escapeHtml(i.appId)+'" style="padding: 5px 12px; font-size: 0.8rem; background: transparent; border: 1px solid var(--bad-fg); color: var(--bad-fg); border-radius: 6px; cursor: pointer;">\u{1F5D1}\uFE0F Remove</button></div></div>'}d+="</div>",d+='<div style="margin-top: 16px; padding: 16px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 10px; border: 1px dashed var(--border);"><h4 style="margin: 0 0 12px; font-size: 0.85rem; color: var(--muted);">\u2795 Add New Schedule</h4><div style="display: flex; gap: 8px; flex-wrap: wrap;"> <input type="text" id="new-schedule-appid" placeholder="App ID (e.g., plex, sonarr)" style="flex: 1; min-width: 150px; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);" /> <select id="new-schedule-interval" style="padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);"> <option value="hourly">Hourly</option> <option value="daily" selected>Daily</option> <option value="weekly">Weekly</option> <option value="monthly">Monthly</option> <option value="6h">Every 6 hours</option> <option value="30m">Every 30 minutes</option> </select> <input type="number" id="new-schedule-retention" value="7" min="1" max="100" placeholder="Keep" style="width: 70px; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);" /> <button id="add-schedule-btn" style="padding: 8px 16px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Add Schedule</button></div></div>',j.innerHTML=d,j.querySelectorAll(".schedule-toggle").forEach(function(B){B.addEventListener("change",function(){m(B.dataset.appid,{enabled:B.checked})})}),j.querySelectorAll(".schedule-select").forEach(function(B){B.addEventListener("change",function(){m(B.dataset.appid,{schedule:B.value})})}),j.querySelectorAll(".retention-input").forEach(function(B){B.addEventListener("change",function(){m(B.dataset.appid,{retention:{keep:parseInt(B.value)||7}})})}),j.querySelectorAll(".schedule-run-now").forEach(function(B){B.addEventListener("click",function(){b(B.dataset.appid)})}),j.querySelectorAll(".schedule-delete").forEach(function(B){B.addEventListener("click",function(){p(B.dataset.appid)})}),document.getElementById("add-schedule-btn")?.addEventListener("click",v)}catch(B){j.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed to load: '+escapeHtml(B.message)+"</div>"}}}async function m(e,o){try{var a=await secureFetch("/api/v1/backups/schedule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e,...o})}),d=await a.json();d.success?showNotification("Schedule updated for "+e,"success"):(showNotification("Update failed: "+(d.error||"Unknown"),"error"),y())}catch(n){showNotification("Error: "+n.message,"error")}}async function b(e){try{var o=await secureFetch("/api/v1/backups/backup/"+encodeURIComponent(e),{method:"POST",headers:{"Content-Type":"application/json"}}),a=await o.json();a.success?showNotification("Backup started for "+e+"!","success"):showNotification("Backup failed: "+(a.error||"Unknown"),"error")}catch(d){showNotification("Error: "+d.message,"error")}}async function p(e){if(confirm("Remove backup schedule for "+e+"?"))try{var o=await secureFetch("/api/v1/backups/schedule/"+encodeURIComponent(e),{method:"DELETE"}),a=await o.json();a.success?(showNotification("Schedule removed for "+e,"success"),y()):showNotification("Delete failed: "+(a.error||"Unknown"),"error")}catch(d){showNotification("Error: "+d.message,"error")}}async function v(){var e=document.getElementById("new-schedule-appid")?.value?.trim(),o=document.getElementById("new-schedule-interval")?.value||"daily",a=parseInt(document.getElementById("new-schedule-retention")?.value)||7;if(!e){showNotification("Please enter an App ID","warning");return}try{var d=await secureFetch("/api/v1/backups/schedule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e,schedule:o,retention:{keep:a},enabled:!0})}),n=await d.json();if(n.success){showNotification("Schedule created for "+e,"success"),y();var i=document.getElementById("new-schedule-appid");i&&(i.value="")}else showNotification("Failed: "+(n.error||"Unknown"),"error")}catch(c){showNotification("Error: "+c.message,"error")}}async function s(){if(O){O.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';try{var e=await fetch("/api/v1/backups/files"),o=await e.json();if(!o.success)throw new Error(o.error||"Failed to load");var a=o.files||[];if(a.length===0){O.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4BE}</span><div>No backup files on disk</div><div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Run a backup to create backup files</div></div>';return}for(var d={},n=0;n<a.length;n++){var i=a[n],c=i.appId||"unknown";d[c]||(d[c]=[]),d[c].push(i)}var C='<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 12px;">'+a.length+" backup file(s) across "+Object.keys(d).length+" app(s)</div>";C+='<div style="display: flex; flex-direction: column; gap: 12px;">';for(var B=Object.keys(d).sort(),F=0;F<B.length;F++){var c=B[F],q=d[c];C+='<div style="background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); overflow: hidden;"><div style="padding: 8px 12px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-bottom: 1px solid var(--border); font-weight: 600; font-size: 0.85rem;">'+escapeHtml(c)+' <span style="font-weight: normal; color: var(--muted); font-size: 0.75rem;">('+q.length+" backup(s))</span></div>";for(var U=0;U<q.length;U++){var i=q[U],G=new Date(i.timestamp).toLocaleString();C+='<div style="padding: 10px 12px; border-bottom: 1px solid var(--border);"><div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;"> <div style="font-weight: 500; font-size: 0.85rem;">'+escapeHtml(i.name)+'</div> <div style="font-size: 0.75rem; color: var(--muted);">'+i.sizeFormatted+'</div></div><div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">'+G+'</div><div style="display: flex; gap: 6px;"> <button class="disk-compare-btn" data-appid="'+escapeHtml(c)+'" data-filename="'+escapeHtml(i.name)+'" style="padding: 3px 8px; font-size: 0.75rem; background: transparent; border: 1px solid var(--accent); color: var(--accent); border-radius: 5px; cursor: pointer;">Compare</button> <button class="disk-restore-btn" data-appid="'+escapeHtml(c)+'" data-filename="'+escapeHtml(i.name)+'" style="padding: 3px 8px; font-size: 0.75rem; background: linear-gradient(135deg, var(--ok-fg), #27ae60); border: none; color: white; border-radius: 5px; cursor: pointer; font-weight: 500;">Restore</button></div></div>'}C+="</div>"}C+="</div>",O.innerHTML=C,O.querySelectorAll(".disk-compare-btn").forEach(function(J){J.addEventListener("click",function(){u(J.dataset.appid,J.dataset.filename)})}),O.querySelectorAll(".disk-restore-btn").forEach(function(J){J.addEventListener("click",function(){t(J.dataset.appid,J.dataset.filename)})})}catch(J){O.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(J.message)+"</div>"}}}async function l(){if(M){M.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';try{var e=await fetch("/api/v1/backups/history?limit=50"),o=await e.json();if(!o.success||!o.history?.length){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span> No backup history yet</div>';return}for(var a='<div style="display: flex; flex-direction: column; gap: 6px;">',d=0;d<o.history.length;d++){var n=o.history[d],i=n.size?(n.size/1024/1024).toFixed(2):"?";a+='<div style="padding: 10px 12px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem;">',a+=' <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">',a+=' <span style="font-weight: 500;">'+escapeHtml(n.name||"backup")+"</span>",a+=' <div style="display: flex; align-items: center; gap: 8px;">',a+=' <span class="status-badge '+(n.status==="success"?"success":"down")+'">'+escapeHtml(n.status)+"</span>",n.status==="success"&&(a+=' <button class="backup-restore-btn" data-backup-id="'+escapeHtml(n.id)+'" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>'),a+=" </div>",a+=" </div>",a+=' <div style="font-size: 0.75rem; color: var(--muted);">',a+=" "+new Date(n.timestamp).toLocaleString()+" | "+i+" MB | "+(n.duration?(n.duration/1e3).toFixed(1)+"s":"--"),n.encrypted&&(a+=" | \u{1F512}"),a+=" </div>",a+="</div>"}a+="</div>",M.innerHTML=a,M.querySelectorAll(".backup-restore-btn").forEach(function(c){c.addEventListener("click",function(){window.__restoreServerBackup(c.dataset.backupId)})})}catch(c){M.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(c.message)+"</div>"}}}window.__restoreServerBackup=async function(e){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var o=await secureFetch("/api/v1/backups/restore/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),a=await o.json();a.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(a.error||"Unknown error"),"error")}catch(d){showNotification("Restore error: "+d.message,"error")}},document.querySelector('[data-panel="backup-schedules-tab"]')?.addEventListener("click",y),document.querySelector('[data-panel="backup-disk-tab"]')?.addEventListener("click",s),document.querySelector('[data-panel="backup-pointintime-tab"]')?.addEventListener("click",r),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",l);async function r(){if(x){try{var e=await fetch("/api/v1/license/status"),o=await e.json();if(o.tier!=="premium"){x.innerHTML=`<div class="panel-empty" style="padding: 24px; text-align: center;"><div style="font-size: 2rem; margin-bottom: 12px;">\u2B50</div><div style="font-weight: 600; margin-bottom: 8px;">Premium Feature</div><div style="font-size: 0.85rem; color: var(--muted);">Point-in-time restore requires DashCaddy Premium with auto-backup enabled.</div><button onclick="showNotification('Upgrade to Premium for point-in-time restore!', 'info'); scrollToSection('license');" style="margin-top: 16px; padding: 8px 20px; background: linear-gradient(135deg, #f39c12, #e67e22); border: none; color: white; border-radius: 8px; cursor: pointer; font-weight: 600;">Upgrade to Premium</button></div>`;return}}catch{}x.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';try{var a=await fetch("/api/v1/services"),d=await a.json(),n=d.services||[];if(n.length===0){x.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span> No apps deployed yet</div>';return}for(var i='<div style="padding: 8px 0 12px;"><div style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px;"> <label style="font-size: 0.85rem; color: var(--muted); white-space: nowrap;">App:</label> <select id="pit-app-select" style="flex: 1; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base); max-width: 240px;">',c=0;c<n.length;c++)i+='<option value="'+escapeHtml(n[c].id||n[c].name||"")+'">'+escapeHtml(n[c].name||n[c].id||"")+"</option>";i+='</select> <button id="pit-load-btn" style="padding: 8px 16px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Load Backups</button></div><div id="pit-backups-list" style="max-height: 320px; overflow-y: auto;"></div></div>',x.innerHTML=i,document.getElementById("pit-load-btn")?.addEventListener("click",function(){var C=document.getElementById("pit-app-select")?.value;C&&f(C)})}catch(C){x.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(C.message)+"</div>"}}}async function f(e){var o=document.getElementById("pit-backups-list");if(o){o.innerHTML='<div style="padding: 20px; text-align: center;"><span class="brand-spinner"></span> Loading backups...</div>';try{var a=await fetch("/api/v1/backups/files/"+encodeURIComponent(e)),d=await a.json();if(!d.success||!d.files||d.files.length===0){o.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4BE}</span> No backup files for '+escapeHtml(e)+"</div>";return}for(var n='<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">'+d.files.length+' backup(s)</div><div style="display: flex; flex-direction: column; gap: 6px;">',i=0;i<d.files.length;i++){var c=d.files[i],C=new Date(c.timestamp).toLocaleString();n+='<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"><div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;"> <div style="font-weight: 500; font-size: 0.85rem;">'+escapeHtml(c.name)+'</div> <div style="font-size: 0.75rem; color: var(--muted);">'+c.sizeFormatted+'</div></div><div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">'+C+'</div><div style="display: flex; gap: 6px;"> <button class="pit-compare-btn" data-appid="'+escapeHtml(e)+'" data-filename="'+escapeHtml(c.name)+'" style="padding: 4px 10px; font-size: 0.75rem; background: transparent; border: 1px solid var(--accent); color: var(--accent); border-radius: 5px; cursor: pointer;">Compare</button> <button class="pit-restore-btn" data-appid="'+escapeHtml(e)+'" data-filename="'+escapeHtml(c.name)+'" style="padding: 4px 10px; font-size: 0.75rem; background: linear-gradient(135deg, var(--ok-fg), #27ae60); border: none; color: white; border-radius: 5px; cursor: pointer; font-weight: 500;">Restore</button></div></div>'}n+="</div>",o.innerHTML=n,o.querySelectorAll(".pit-compare-btn").forEach(function(B){B.addEventListener("click",function(){u(B.dataset.appid,B.dataset.filename)})}),o.querySelectorAll(".pit-restore-btn").forEach(function(B){B.addEventListener("click",function(){t(B.dataset.appid,B.dataset.filename)})})}catch(B){o.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(B.message)+"</div>"}}}async function u(e,o){try{var a=await secureFetch("/api/v1/backups/compare/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({})}),d=await a.json();if(!d.success){showNotification("Compare failed: "+(d.error||"Unknown"),"error");return}var n=d.diff,i='<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;" id="compare-overlay"><div style="background: var(--base); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; padding: 20px; border: 1px solid var(--border);"><h4 style="margin: 0 0 16px;">\u{1F4CA} Compare: '+escapeHtml(o)+'</h4><div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 16px;">Size: '+(n.sizeFormatted||"?")+" | Created: "+new Date(n.timestamp).toLocaleString()+"</div>";if(n.services){var c=n.services.hasChanges?"\u{1F534}":"\u{1F7E2}";i+='<div style="margin-bottom: 12px; padding: 10px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border);"><div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 4px;">'+c+' Services (backup vs current)</div><div style="font-size: 0.8rem; color: var(--muted);">Backup: '+n.services.backupCount+" services | Current: "+n.services.currentCount+" services</div>",n.services.hasChanges&&(i+='<div style="margin-top: 6px; font-size: 0.8rem; color: #f39c12;">Services differ \u2014 restoring will replace current configuration</div>'),i+="</div>"}if(n.config){var C=n.config.hasChanges?"\u{1F534}":"\u{1F7E2}";i+='<div style="margin-bottom: 12px; padding: 10px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border);"><div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 4px;">'+C+" Configuration</div>",n.config.hasChanges?i+='<div style="font-size: 0.8rem; color: #f39c12;">Configuration differs \u2014 restoring will replace current settings</div>':i+='<div style="font-size: 0.8rem; color: var(--ok-fg);">No changes</div>',i+="</div>"}i+='<button id="compare-close-btn" style="padding: 8px 20px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Close</button></div></div>',document.body.insertAdjacentHTML("beforeend",i),document.getElementById("compare-close-btn")?.addEventListener("click",function(){document.getElementById("compare-overlay")?.remove()}),document.getElementById("compare-overlay")?.addEventListener("click",function(B){B.target===this&&this.remove()})}catch(B){showNotification("Compare error: "+B.message,"error")}}async function t(e,o){if(confirm("Restore "+o+" for "+e+`?
|
|
|
|
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),d=await a.json();d.success?(showNotification(e+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(d.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;">
|
|
<h3>\u{1F4CA} Resource Monitor</h3>
|
|
<p class="modal-subtitle">
|
|
Real-time and historical CPU, memory, network, and disk usage for containers.
|
|
</p>
|
|
|
|
<!-- Tab bar -->
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-panel="stats-live">Live Stats</button>
|
|
<button class="panel-tab" data-panel="stats-aggregated">24h Summary</button>
|
|
<button class="panel-tab" data-panel="stats-history">History</button>
|
|
<button class="panel-tab" data-panel="stats-alerts">Alerts</button>
|
|
</div>
|
|
|
|
<!-- Tab: Live Stats -->
|
|
<div id="stats-live" class="panel-section active">
|
|
<div id="stats-container" class="scroll-container">
|
|
<div style="text-align: center; padding: 40px; color: var(--muted);">
|
|
<span class="brand-spinner"></span> Loading container stats...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: 24h Aggregated Summary -->
|
|
<div id="stats-aggregated" class="panel-section">
|
|
<div id="stats-aggregated-container" class="scroll-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u{1F4C8}</span>
|
|
Loading 24-hour aggregated metrics...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Long-term History -->
|
|
<div id="stats-history" class="panel-section">
|
|
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap;">
|
|
<label style="font-size: 0.85rem; color: var(--muted);">Container:</label>
|
|
<select id="stats-history-container" style="padding: 4px 8px; background: var(--card-base); border: 1px solid var(--border); color: var(--fg); border-radius: 4px; font-size: 0.85rem; flex: 1; min-width: 180px;"></select>
|
|
<div id="stats-history-range-buttons" style="display: flex; gap: 4px;">
|
|
<button class="stats-range-btn active" data-range="1h">1h</button>
|
|
<button class="stats-range-btn" data-range="24h">24h</button>
|
|
<button class="stats-range-btn" data-range="7d">7d</button>
|
|
<button class="stats-range-btn" data-range="30d">30d</button>
|
|
<button class="stats-range-btn" data-range="1y">1y</button>
|
|
</div>
|
|
</div>
|
|
<div id="stats-history-container-area" class="scroll-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u{1F4CA}</span>
|
|
Choose a container and time range to view history.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Alert Configuration -->
|
|
<div id="stats-alerts" class="panel-section">
|
|
<div id="stats-alerts-container" class="scroll-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u{1F514}</span>
|
|
Loading alert configurations...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auto-refresh toggle (bottom bar) -->
|
|
<div class="panel-bottom-bar">
|
|
<label class="checkbox-label" style="font-size: 0.85rem;">
|
|
<input type="checkbox" id="stats-auto-refresh" checked />
|
|
Auto-refresh every 5s
|
|
</label>
|
|
<button id="stats-refresh-btn" class="btn-sm">\u{1F504} Refresh Now</button>
|
|
<span id="stats-last-update" class="text-auto-right"></span>
|
|
</div>
|
|
|
|
<!-- Close Button -->
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="stats-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("stats-modal"),S=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),k=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),P=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let T=null,w=null;function H(r){if(r===0||!r)return"0 B";const f=1024,u=["B","KB","MB","GB"],t=Math.floor(Math.log(r)/Math.log(f));return parseFloat((r/Math.pow(f,t)).toFixed(1))+" "+u[t]}function E(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function $(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function I(){try{let r=null,f=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,f=!0,w=e.stats)}catch{}if(!f){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const o of e.stats)r[o.name]={name:o.name,current:{cpu:o.cpu,memory:{percent:o.memory.percent,usage:o.memory.used,limit:o.memory.limit,usageMB:Math.round(o.memory.used/1048576),limitMB:Math.round(o.memory.limit/1048576)},network:{rxBytes:o.network.rx,txBytes:o.network.tx,rxMB:(o.network.rx/1048576).toFixed(1),txMB:(o.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:o.status};w=r}}if(!r||Object.keys(r).length===0){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(r)){const o=e.current||e,a=o.cpu?.percent||0,d=o.memory?.percent||0,n=E(a),i=$(d),c=o.memory?.usage||o.memory?.used||0,C=o.memory?.limit||0,B=o.network?.rxBytes||o.network?.rx||0,F=o.network?.txBytes||o.network?.tx||0,q=e.aggregated;u+=`
|
|
<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>
|
|
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
|
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${n}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
</div>
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
<div style="height: 100%; width: ${Math.min(d,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
</div>
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${d.toFixed(1)}%</span>
|
|
</div>
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(c)} / ${H(C)}</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
|
<div style="font-size: 0.8rem;">
|
|
<span style="color: #3498db;">\u2193 ${H(B)}</span>
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
<span style="color: #e74c3c;">\u2191 ${H(F)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`}u+="</div>",R.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){R.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(r.message)}</div>`}}async function L(){if(!z)return;const r=w;if(!r||Object.keys(r).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,t]of Object.entries(r)){const e=t.aggregated;e&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||u}</div>
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${e.memory?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg Mem</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
|
</div>
|
|
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
|
|
</div>`)}f+="</div>",z.innerHTML=f}async function j(){if(!P)return;P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const r=w;if(!r||Object.keys(r).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let u=[];try{const i=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();i.success&&(u=i.history||[])}catch{}let t={};try{const i=await(await fetch("/api/v1/monitoring/alerts/config")).json();i.success&&(t=i.configs||{})}catch{}const o=Object.entries(r).map(([n,i])=>{const c=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
|
<tr data-container="${n}">
|
|
<td style="font-weight: 600; padding: 8px;">${i.name||n}</td>
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${c.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${c.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${c.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
|
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${c.autoRestart?"checked":""} ${f?"":"disabled"} /></td>
|
|
<td style="padding: 4px 8px;">
|
|
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${i.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
|
</td>
|
|
</tr>
|
|
`}).join(""),a=u.map(n=>{const i=new Date(n.timestamp).toLocaleString(),c=n.notified?"\u2713":"\u2014";return`
|
|
<tr>
|
|
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${i}</td>
|
|
<td style="padding: 6px 8px; font-weight: 500;">${n.containerName||n.containerId}</td>
|
|
<td style="padding: 6px 8px; text-transform: capitalize;">${n.metric||n.type}</td>
|
|
<td style="padding: 6px 8px;">${typeof n.value=="number"?n.value.toFixed(1):n.value}${n.metric==="disk"?" MB/s":"%"}</td>
|
|
<td style="padding: 6px 8px; text-align: center;">${c}</td>
|
|
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td>
|
|
</tr>
|
|
`}).join(""),d=f?`
|
|
<div style="margin-bottom: 20px;">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
|
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
|
|
<a href="#" id="go-to-notifications" style="font-size: 0.8rem; color: var(--accent); text-decoration: none;">Configure notifications \u2192</a>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">
|
|
<thead>
|
|
<tr style="border-bottom: 1px solid var(--border);">
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">CPU %</th>
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Mem %</th>
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Disk I/O MB/s</th>
|
|
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">Auto-Restart</th>
|
|
<th style="padding: 6px 8px;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${o}</tbody>
|
|
</table>
|
|
</div>
|
|
<div style="margin-top: 12px; display: flex; justify-content: flex-end;">
|
|
<button id="save-all-alerts" style="padding: 6px 16px; font-size: 0.85rem; background: var(--accent); color: var(--base); border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Save All</button>
|
|
</div>
|
|
</div>
|
|
`:`
|
|
<div style="margin-bottom: 20px; padding: 12px; background: rgba(241,196,15,0.1); border: 1px solid rgba(241,196,15,0.3); border-radius: 8px; text-align: center;">
|
|
<span style="color: #f1c40f; font-weight: 600; font-size: 0.85rem;">\u2B50 Premium Feature</span>
|
|
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
|
|
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
|
|
</div>
|
|
`;P.innerHTML=`
|
|
${d}
|
|
<div>
|
|
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
|
|
${a?`
|
|
<div style="overflow-x: auto;">
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
|
|
<thead>
|
|
<tr style="border-bottom: 1px solid var(--border);">
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Time</th>
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Metric</th>
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Value</th>
|
|
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">\u2713?</th>
|
|
<th style="padding: 6px 8px;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${a}</tbody>
|
|
</table>
|
|
</div>
|
|
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
|
|
</div>
|
|
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;n[c]={cpuThreshold:parseInt(i.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(i.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(i.querySelector(".alert-disk")?.value)||50,autoRestart:!!i.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const c=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),C=document.getElementById("save-all-alerts");C.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{C.textContent="Save All"},2e3)}catch{const c=document.getElementById("save-all-alerts");c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),h.classList.remove("show"),O(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=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=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),O(),typeof openLicenseModal=="function"&&openLicenseModal()})}function M(){T&&clearInterval(T),N?.checked&&(T=setInterval(I,DC.POLL.STATS))}function O(){T&&(clearInterval(T),T=null)}S?.addEventListener("click",()=>{h.classList.add("show"),I(),M()}),A?.addEventListener("click",()=>{h.classList.remove("show"),O()}),h?.addEventListener("click",r=>{r.target===h&&(h.classList.remove("show"),O())}),k?.addEventListener("click",I),N?.addEventListener("change",()=>{N.checked?M():O()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",L),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),y=document.querySelectorAll(".stats-range-btn");let m="1h";function b(r){switch(r){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 p(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function v(r,f,u,t,e){if(!r||r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=r.map(f).filter(G=>G!=null);if(o.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=Math.max(...o,1),d=Math.min(...o,0),n=a-d||1,i=600,c=80,C=4,B=(i-C*2)/Math.max(o.length-1,1),F=o.map((G,J)=>{const X=C+J*B,Q=c-C-(G-d)/n*(c-C*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=o[o.length-1],U=o.reduce((G,J)=>G+J,0)/o.length;return`
|
|
<div style="margin-bottom: 14px;">
|
|
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
|
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}</span>
|
|
</div>
|
|
<svg viewBox="0 0 ${i} ${c}" preserveAspectRatio="none" style="width: 100%; height: ${c}px; background: var(--base); border-radius: 4px;">
|
|
<polyline fill="none" stroke="${u}" stroke-width="1.5" points="${F}" />
|
|
</svg>
|
|
</div>
|
|
`}function s(){if(!x)return;const r=w||{},f=x.value,u=Object.entries(r);if(u.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=u.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),f&&r[f]&&(x.value=f)}async function l(){if(!D||!x)return;const r=x.value;if(!r){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const f=Date.now(),u=f-b(m);D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(r)}?startTime=${u}&endTime=${f}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const o=e.samples||[],a=e.tier||"raw";if(o.length===0){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${p(a)}.</div>`;return}const d=a==="raw",n=d?F=>F.cpu?.percent:F=>F.cpu?.avg,i=d?F=>F.memory?.percent:F=>F.memory?.avgPercent,c=d?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,C=d?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let B=`
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
|
${o.length} samples \xB7 ${escapeHtml(p(a))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(f).toLocaleString()}
|
|
</div>
|
|
`;B+=v(o,n,"#2ecc71","CPU","%"),B+=v(o,i,"#3498db","Memory","%"),B+=v(o,c,"#9b59b6","Network RX"," MB"),B+=v(o,C,"#e67e22","Network TX"," MB"),D.innerHTML=B}catch(t){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}y.forEach(r=>{r.addEventListener("click",()=>{y.forEach(f=>f.classList.remove("active")),r.classList.add("active"),m=r.dataset.range,l()})}),x?.addEventListener("change",l),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),l()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
|
<h3>\u{1F3E5} Health Check Dashboard</h3>
|
|
<p class="modal-subtitle">
|
|
Service uptime tracking, SLA monitoring, and incident management.
|
|
</p>
|
|
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-panel="health-status">Status</button>
|
|
<button class="panel-tab" data-panel="health-incidents">Incidents</button>
|
|
<button class="panel-tab" data-panel="health-config">Configure</button>
|
|
</div>
|
|
|
|
<!-- Tab: Status -->
|
|
<div id="health-status" class="panel-section active">
|
|
<div id="health-status-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading health status...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Incidents -->
|
|
<div id="health-incidents" class="panel-section">
|
|
<div id="health-incidents-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span> Loading incidents...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Configure -->
|
|
<div id="health-config" class="panel-section">
|
|
<div id="health-config-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span> Loading configuration...</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit Health Check Form -->
|
|
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
|
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
<div>
|
|
<label class="text-muted-sm">Service ID</label>
|
|
<input type="text" id="health-form-id" placeholder="e.g. plex" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Display Name</label>
|
|
<input type="text" id="health-form-name" placeholder="e.g. Plex Media Server" class="form-input" />
|
|
</div>
|
|
<div style="grid-column: span 2;">
|
|
<label class="text-muted-sm">URL</label>
|
|
<input type="text" id="health-form-url" placeholder="https://plex.home" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Timeout (ms)</label>
|
|
<input type="number" id="health-form-timeout" value="10000" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Expected Status Codes</label>
|
|
<input type="text" id="health-form-codes" value="200" placeholder="200, 301, 302" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">SLA Target (%)</label>
|
|
<input type="number" id="health-form-sla" value="99.9" step="0.1" class="form-input" />
|
|
</div>
|
|
<div>
|
|
<label class="text-muted-sm">Slow Response Threshold (ms)</label>
|
|
<input type="number" id="health-form-slow" value="5000" class="form-input" />
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end;">
|
|
<button id="health-form-cancel">Cancel</button>
|
|
<button id="health-form-save" class="btn-accent-solid">Save</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 12px;">
|
|
<button id="health-add-btn" class="btn-accent-solid">+ Add Health Check</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-bottom-bar">
|
|
<button id="health-refresh-btn" class="btn-sm">\u{1F504} Refresh</button>
|
|
<span id="health-last-update" class="text-auto-right"></span>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="health-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("health-modal"),S=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),k=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),P=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),E=document.getElementById("health-form-save");let $=null;function I(y){return y>=99.9?"var(--ok-fg)":y>=95?"#f39c12":"var(--bad-fg)"}function L(y){const m={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: ${m[y]||"var(--muted)"}20; color: ${m[y]||"var(--muted)"};">${y}</span>`}async function j(){try{const m=await(await fetch("/api/v1/health-checks/status")).json();if(!m.success||!m.status||Object.keys(m.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const b=Object.values(m.status);let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',p+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',p+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',p+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const v of b){const s=v.status==="up",l=s?"var(--dot-ok)":"var(--dot-bad)",r=v.uptime?.["24h"]??"-",f=v.uptime?.["7d"]??"-",u=v.avgResponseTime!=null?Math.round(v.avgResponseTime)+"ms":"-",t=v.timestamp?timeAgo(v.timestamp):"-";p+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(v.serviceId)}">`,p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(v.name||v.serviceId)}</td>`,p+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${l}; margin-right: 6px;"></span>${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px; color: ${typeof r=="number"?I(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,p+=`<td style="padding: 8px; color: ${typeof f=="number"?I(f):"var(--muted)"};">${typeof f=="number"?f.toFixed(1)+"%":f}</td>`,p+=`<td style="padding: 8px;">${u}</td>`,p+=`<td style="padding: 8px; color: var(--muted);">${t}</td>`,p+="</tr>",p+=`<tr id="health-detail-${escapeHtml(v.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}p+="</table>",N.innerHTML=p,P.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(v=>{v.addEventListener("click",async()=>{const s=v.dataset.healthId,l=document.getElementById("health-detail-"+s);if(l){if(l.style.display!=="none"){l.style.display="none";return}l.style.display="";try{const f=await(await fetch(`/api/v1/health-checks/${s}/stats?hours=24`)).json();if(f.success&&f.stats){const u=f.stats,t=u.responseTime||{};l.querySelector("td").innerHTML=`
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${u.totalChecks||0}</strong></div>
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${I(u.uptime||0)};">${(u.uptime||0).toFixed(2)}%</strong></div>
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(t.avg||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(t.p95||0)}ms / ${Math.round(t.p99||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(t.min||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(t.max||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${u.upChecks||0}</strong></div>
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${u.downChecks||0}</strong></div>
|
|
</div>`}else l.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){l.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(y.message)}</div>`}}async function M(){try{const[y,m]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),b=await y.json(),p=await m.json();let v="";const s=b.success&&b.incidents?b.incidents:[];if(s.length>0){v+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+s.length+")</h4>";for(const r of s)v+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span>
|
|
<span>${L(r.severity)}</span>
|
|
</div>
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div>
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)</div>
|
|
</div>`;v+="</div>"}else v+='<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 l=p.success&&p.history?p.history:[];if(l.length>0){v+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',v+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const r of l){const f=r.status==="resolved",u=f&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,v+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,v+=`<td style="padding: 6px;">${L(r.severity)}</td>`,v+=`<td style="padding: 6px;"><span style="color: ${f?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,v+=`<td style="padding: 6px;">${u}</td>`,v+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,v+="</tr>"}v+="</table>"}R.innerHTML=v||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function O(){try{const m=await(await fetch("/api/v1/health-checks/status")).json(),b=m.success&&m.status?Object.values(m.status):[];if(b.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<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 v of b){const s=v.status==="up";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(v.name||v.serviceId)}</td>`,p+=`<td style="padding: 8px; color: ${s?"var(--ok-fg)":"var(--bad-fg)"};">${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px;">${v.sla?.target?v.sla.target+"%":"-"}</td>`,p+='<td style="padding: 8px; text-align: right;">',p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(v.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(v.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,p+="</td></tr>"}p+="</table>",z.innerHTML=p}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}function x(y,m,b,p,v,s,l){$=y||null,w.textContent=y?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=y||"",document.getElementById("health-form-id").disabled=!!y,document.getElementById("health-form-name").value=m||"",document.getElementById("health-form-url").value=b||"",document.getElementById("health-form-timeout").value=p||1e4,document.getElementById("health-form-codes").value=v||"200",document.getElementById("health-form-sla").value=s||99.9,document.getElementById("health-form-slow").value=l||5e3,T.style.display="",g.style.display="none"}function D(){T.style.display="none",g.style.display="",$=null}g?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",D),E?.addEventListener("click",async()=>{const y=$||document.getElementById("health-form-id").value.trim();if(!y)return showNotification("Service ID is required","warning");const m=document.getElementById("health-form-url").value.trim();if(!m)return showNotification("URL is required","warning");const b=document.getElementById("health-form-codes").value.split(",").map(v=>parseInt(v.trim())).filter(Boolean),p={name:document.getElementById("health-form-name").value.trim()||y,url:m,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:b.length?b:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)||5e3};try{E.textContent="Saving...",E.disabled=!0;const s=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(y)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(p)})).json();if(!s.success)throw new Error(s.error||"Save failed");D(),O(),j()}catch(v){showNotification("Error: "+v.message,"error")}finally{E.textContent="Save",E.disabled=!1}}),document.addEventListener("health-edit",async y=>{const m=y.detail;x(m,"","",1e4,"200",99.9,5e3)}),document.addEventListener("health-delete",async y=>{const m=y.detail;if(confirm(`Delete health check for "${m}"?`))try{const p=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(m)}/configure`,{method:"DELETE"})).json();if(!p.success)throw new Error(p.error);O(),j()}catch(b){showNotification("Error: "+b.message,"error")}}),S?.addEventListener("click",()=>{h?.classList.add("show"),j()}),wireModal(h,A),k?.addEventListener("click",j),document.querySelector('[data-panel="health-incidents"]')?.addEventListener("click",M),document.querySelector('[data-panel="health-config"]')?.addEventListener("click",O)})(),(function(){injectModal("updates-modal",`<div id="updates-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
|
<h3>\u2B06\uFE0F Update Management</h3>
|
|
<p class="modal-subtitle">
|
|
Check for container image updates, apply them, and manage rollbacks.
|
|
</p>
|
|
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-panel="updates-available">Available</button>
|
|
<button class="panel-tab" data-panel="updates-history">History</button>
|
|
<button class="panel-tab" data-panel="updates-auto">Auto-Update</button>
|
|
<button class="panel-tab" data-panel="updates-dashcaddy" id="updates-dashcaddy-tab">DashCaddy</button>
|
|
</div>
|
|
|
|
<!-- Tab: Available Updates -->
|
|
<div id="updates-available" class="panel-section active">
|
|
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
|
|
<button id="updates-check-btn" class="btn-accent-solid">\u{1F50D} Check for Updates</button>
|
|
<button id="updates-update-all-btn" style="display: none; padding: 6px 14px; font-size: 0.82rem; background: #f97316; color: #fff; border: 1px solid #f97316; border-radius: 6px; cursor: pointer;">\u2B06\uFE0F Update All</button>
|
|
<span id="updates-count-badge" style="display: none; padding: 4px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; background: var(--accent); color: var(--bg);"></span>
|
|
</div>
|
|
<div id="updates-available-container" style="max-height: 450px; overflow-y: auto;">
|
|
<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span> Click "Check for Updates" to scan containers.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: History -->
|
|
<div id="updates-history" class="panel-section">
|
|
<div id="updates-history-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading update history...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Auto-Update -->
|
|
<div id="updates-auto" class="panel-section">
|
|
<div id="updates-auto-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="empty-icon">\u{1F916}</span> Loading auto-update configuration...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: DashCaddy Self-Update -->
|
|
<div id="updates-dashcaddy" class="panel-section">
|
|
<div id="dashcaddy-version-info" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: var(--bg);">
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 600; font-size: 1rem;">DashCaddy</div>
|
|
<div id="dashcaddy-current-version" style="color: var(--muted); font-size: 0.85rem;">Loading...</div>
|
|
</div>
|
|
<div id="dashcaddy-update-badge" style="display: none; padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; background: var(--accent); color: var(--bg);">Update available</div>
|
|
</div>
|
|
<div id="dashcaddy-update-details" style="display: none; margin-bottom: 16px; padding: 12px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
<span style="font-weight: 600;">New version: <span id="dashcaddy-new-version"></span></span>
|
|
<button id="dashcaddy-apply-btn" class="btn-accent-solid" style="padding: 6px 16px; font-size: 0.85rem;">Update Now</button>
|
|
</div>
|
|
<div id="dashcaddy-changelog" style="font-size: 0.8rem; color: var(--muted); max-height: 120px; overflow-y: auto; white-space: pre-wrap; font-family: var(--font-mono); line-height: 1.5;"></div>
|
|
</div>
|
|
<div id="dashcaddy-status-bar" style="display: none; margin-bottom: 16px; padding: 10px 12px; border-radius: 8px; font-size: 0.85rem;"></div>
|
|
<div style="margin-bottom: 12px;">
|
|
<button id="dashcaddy-check-btn" style="padding: 6px 14px; font-size: 0.82rem;">Check for Updates</button>
|
|
<button id="dashcaddy-rollback-btn" style="padding: 6px 14px; font-size: 0.82rem; margin-left: 6px;">Rollback</button>
|
|
</div>
|
|
<div id="dashcaddy-history-container" style="max-height: 250px; overflow-y: auto;">
|
|
<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-bottom-bar">
|
|
<span id="updates-last-check" class="text-auto-right"></span>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="updates-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("updates-modal"),S=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),k=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),P=document.getElementById("updates-last-check");async function g(){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){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',P.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const i=(()=>{const c=window.APPS||[];for(const C of c)if(C.containerId===n.containerId||C.name===n.containerName||C.id===n.containerName)return C.id;return n.containerName})();o+=`<tr data-app-id="${escapeHtml(i)}" style="border-bottom: 1px solid var(--border);">`,o+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,o+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,o+='<td style="padding: 8px; text-align: right;">',o+=`<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>`,o+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,o+="</td></tr>"}o+="</table>",N.innerHTML=o,P.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),d=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),d&&e.length>0&&(d.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.dataset.id,c=n.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const B=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(B.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(B.error||"Update failed")}catch(C){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+C.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.dataset.id,c=n.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const B=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(B.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(B.error||"Rollback failed")}catch(C){n.textContent="Failed",showNotification("Rollback error: "+C.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(u){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.message)}</div>`}}async function T(){const u=window._pendingUpdates||[];if(!u.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${u.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,o=0;for(const a of u)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:o++}catch{o++}t.textContent="\u2705 Done",showNotification(`Update all: ${e} succeeded, ${o} failed.`,e>0&&o===0?"success":"error"),setTimeout(()=>{t.textContent="\u2B06\uFE0F Update All",t.disabled=!1,g()},3e3)}document.getElementById("updates-update-all-btn")?.addEventListener("click",T);async function w(){k.textContent="\u{1F50D} Checking...",k.disabled=!0;try{const t=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!t.success)throw new Error(t.error);k.textContent="\u2705 Done!",await g()}catch(u){k.textContent="\u274C Failed",showNotification("Check error: "+u.message,"error")}setTimeout(()=>{k.textContent="\u{1F50D} Check for Updates",k.disabled=!1},3e3)}async function H(){try{R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const t=await(await fetch("/api/v1/updates/history?limit=50")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">Status</th></tr>';for(const a of e){const d=a.status==="success",n=a.duration?a.duration<1e3?a.duration+"ms":Math.round(a.duration/1e3)+"s":"-";o+='<tr style="border-bottom: 1px solid var(--border);">',o+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(a.timestamp)}</td>`,o+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(a.containerName)}</td>`,o+=`<td style="padding: 6px; color: var(--muted);">${escapeHtml(a.imageName)}</td>`,o+=`<td style="padding: 6px;">${n}</td>`,o+=`<td style="padding: 6px;"><span style="color: ${d?"var(--ok-fg)":"var(--bad-fg)"};">${d?"\u2713 success":"\u2717 failed"}</span></td>`,o+="</tr>",!d&&a.error&&(o+=`<tr><td colspan="5" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">${escapeHtml(a.error)}</td></tr>`)}o+="</table>",R.innerHTML=o}catch(u){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.message)}</div>`}}async function E(){try{z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const[u,t]=await Promise.all([fetch("/api/v1/stats/containers"),fetch("/api/v1/updates/auto-update")]),e=await u.json(),o=await t.json(),a=e.success&&e.stats?e.stats:[],d=o.success&&o.config?o.config:{};if(a.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F916}</span>No running containers found.</div>';return}let n='<div style="margin-bottom: 12px; font-size: 0.8rem; color: var(--muted);">Auto-updates run during maintenance window (default 2AM-4AM). Daily = every day, Weekly = Sundays, Monthly = 1st of month.</div>';n+='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">',n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Schedule</th><th style="padding: 8px; text-align: left;">Window</th><th style="padding: 8px; text-align: left;">Rollback</th><th style="padding: 8px; text-align: left;">Last Run</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const i of a){const c=i.name||i.Names?.[0]?.replace(/^\//,"")||i.Id?.substring(0,12),C=i.containerId||i.Id,B=d[C]||{},F=B.enabled?B.schedule||"weekly":"",q=B.autoRollback!==!1,U=B.maintenanceWindow||"",G=B.lastAutoUpdate?timeAgo(B.lastAutoUpdate):"Never";n+=`<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(C)}">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(c)}</td>`,n+=`<td style="padding: 8px;">
|
|
<select class="auto-schedule" data-id="${escapeHtml(C)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
<option value=""${F?"":" selected"}>Disabled</option>
|
|
<option value="daily"${F==="daily"?" selected":""}>Daily</option>
|
|
<option value="weekly"${F==="weekly"?" selected":""}>Weekly</option>
|
|
<option value="monthly"${F==="monthly"?" selected":""}>Monthly</option>
|
|
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(C)}" value="${escapeHtml(U)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(C)}"${q?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(C)}" data-name="${escapeHtml(c)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",z.innerHTML=n,z.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,C=i.closest("tr"),B=C.querySelector(".auto-schedule").value,F=C.querySelector(".auto-rollback").checked,q=C.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!B,schedule:B||"weekly",autoRollback:F,maintenanceWindow:q||void 0})})).json();if(G.success)i.textContent="\u2713 Saved";else throw new Error(G.error)}catch(U){i.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(u){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),I=document.getElementById("dashcaddy-update-badge"),L=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),M=document.getElementById("dashcaddy-changelog"),O=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),y=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let b=null;function p(u,t){y&&(y.style.display="block",y.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",y.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",y.textContent=u)}async function v(){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(u){u||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(b=e,e.success&&e.available&&e.remote){I.style.display="",L.style.display="",j.textContent="v"+e.remote.version,M.textContent=e.remote.changelog||"No changelog available.";const o=document.getElementById("updates-btn");if(o&&!o.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",o.style.position="relative",o.appendChild(d)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(d)}}else I.style.display="none",L.style.display="none",await v(),u||p("You are running the latest version.","success");u||(x.textContent="Check for Updates",x.disabled=!1)}catch(t){u||(p("Failed to check: "+t.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function l(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;O.textContent="Updating...",O.disabled=!0,p("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return p("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),O.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(u){throw p("Update failed: "+u.message,"error"),O.textContent="Update Now",O.disabled=!1,u}}async function r(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Version</th><th style="padding: 6px; text-align: left;">From</th><th style="padding: 6px; text-align: left;">Status</th></tr>';for(const a of e){const d=a.status==="success"?"\u2713 success":a.status==="pending"?"\u23F3 pending":a.status==="partial"?"\u26A0 partial":"\u2717 "+a.status,n=a.status==="success"?"var(--ok-fg)":a.status==="pending"?"var(--muted)":"var(--bad-fg)";o+='<tr style="border-bottom: 1px solid var(--border);">',o+='<td style="padding: 6px; color: var(--muted);">'+timeAgo(a.timestamp)+"</td>",o+='<td style="padding: 6px; font-weight: 500;">v'+escapeHtml(a.version)+(a.rollback?" (rollback)":"")+"</td>",o+='<td style="padding: 6px; color: var(--muted);">v'+escapeHtml(a.fromVersion||"?")+"</td>",o+='<td style="padding: 6px;"><span style="color: '+n+';">'+d+"</span></td>",o+="</tr>",a.error&&(o+='<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">'+escapeHtml(a.error)+"</td></tr>"),a.note&&(o+='<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--muted);">'+escapeHtml(a.note)+"</td></tr>")}o+="</table>",m.innerHTML=o}catch(u){m.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(u.message)+"</div>"}}async function f(){try{const t=await(await fetch("/api/v1/system/rollback-versions")).json(),e=t.success&&t.versions?t.versions:[];if(e.length===0){showNotification("No rollback versions available.","info");return}const o=prompt(`Available rollback versions:
|
|
`+e.join(`
|
|
`)+`
|
|
|
|
Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification("Invalid version: "+o,"error");return}if(!confirm("Rollback DashCaddy to v"+o+"? The container will restart."))return;p("Rolling back to v"+o+"...","info");const d=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:o})})).json();if(d.success)p("Rollback to v"+o+" initiated. Container will restart.","success");else throw new Error(d.error||"Rollback failed")}catch(u){p("Rollback failed: "+u.message,"error")}}x?.addEventListener("click",()=>s(!1)),O?.addEventListener("click",()=>l().catch(()=>{})),D?.addEventListener("click",f),k?.addEventListener("click",w),S?.addEventListener("click",()=>{h?.classList.add("show"),g()}),wireModal(h,A),window.openUpdateModal=function(u){h?.classList.add("show"),g().then(()=>{if(!u)return;const t=N.querySelector(`[data-app-id="${u}"]`);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",E),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),r(),b||s(!0)}),window.dcApplyUpdate=l,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>
|
|
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-panel="dr-volumes">Volumes</button>
|
|
<button class="panel-tab" data-panel="dr-networks">Networks</button>
|
|
<button class="panel-tab" data-panel="dr-disk">Disk Usage</button>
|
|
</div>
|
|
|
|
<!-- Volumes -->
|
|
<div id="dr-volumes" class="panel-section active">
|
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
|
<input type="text" id="dr-vol-name" placeholder="Volume name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
|
<button id="dr-vol-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
|
</div>
|
|
<div id="dr-vol-list" class="scroll-container" style="max-height: 400px;">
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Networks -->
|
|
<div id="dr-networks" class="panel-section">
|
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
|
<input type="text" id="dr-net-name" placeholder="Network name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
|
<select id="dr-net-driver" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
|
|
<option value="bridge">bridge</option>
|
|
<option value="overlay">overlay</option>
|
|
<option value="host">host</option>
|
|
</select>
|
|
<button id="dr-net-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
|
</div>
|
|
<div id="dr-net-list" class="scroll-container" style="max-height: 400px;">
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Disk Usage -->
|
|
<div id="dr-disk" class="panel-section">
|
|
<div id="dr-disk-content">
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="dr-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("docker-resources-modal"),S=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function k(P){if(!P||P===0)return"0 B";const g=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(P))/Math.log(1024));return(P/Math.pow(1024,T)).toFixed(1)+" "+g[T]}async function N(){const P=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const E=H.name==="buildkit"||H.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(H.name)}">${escapeHtml(H.name.length>40?H.name.substring(0,37)+"...":H.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',E||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-vol-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete volume "${H.dataset.name}"? Data will be lost.`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(H.dataset.name)}?force=true`),N()}catch(E){showNotification("Delete failed: "+E.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-vol-name"),g=P.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),P.value="",showNotification(`Volume "${g}" created`,"success"),N()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function R(){const P=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const E=["bridge","host","none"].includes(H.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',E||(w+=`<button class="dr-net-del" data-id="${escapeHtml(H.id)}" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.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)}`),R()}catch(E){showNotification("Delete failed: "+E.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),T=P.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:g.value}),P.value="",showNotification(`Network "${T}" created`,"success"),R()}catch(w){showNotification("Create failed: "+w.message,"error")}});async function z(){const P=document.getElementById("dr-disk-content");try{const g=await getJSON("/api/v1/docker/disk-usage"),T=[{label:"Images",icon:"\u{1F4C0}",count:g.images.count,size:g.images.size,reclaimable:g.images.reclaimable},{label:"Containers",icon:"\u{1F4E6}",count:g.containers.count,size:g.containers.size,extra:`${g.containers.running} running`},{label:"Volumes",icon:"\u{1F4BE}",count:g.volumes.count,size:g.volumes.size,reclaimable:g.volumes.reclaimable},{label:"Build Cache",icon:"\u{1F527}",count:g.buildCache.count,size:g.buildCache.size,reclaimable:g.buildCache.reclaimable}];let w=`<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 16px;">Total: ${k(g.totalSize)}</div>`;w+='<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">';for(const H of T)w+='<div style="padding: 14px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border);">',w+=`<div style="font-weight: 600; margin-bottom: 6px;">${H.icon} ${H.label} <span style="color: var(--muted); font-weight: 400; font-size: 0.82rem;">(${H.count})</span></div>`,w+=`<div style="font-size: 1.1rem; font-weight: 600; color: var(--accent);">${k(H.size)}</div>`,H.reclaimable>0&&(w+=`<div style="font-size: 0.78rem; color: var(--muted);">Reclaimable: ${k(H.reclaimable)}</div>`),H.extra&&(w+=`<div style="font-size: 0.78rem; color: var(--muted);">${H.extra}</div>`),w+="</div>";w+="</div>",P.innerHTML=w}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}S?.addEventListener("click",()=>{h?.classList.add("show"),N()}),wireModal(h,A),document.querySelector('[data-panel="dr-networks"]')?.addEventListener("click",R),document.querySelector('[data-panel="dr-disk"]')?.addEventListener("click",z)})(),(function(){injectModal("compose-import-modal",`<div id="compose-import-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
|
|
<h3>\u{1F4E6} Import Docker Compose</h3>
|
|
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
|
|
|
|
<!-- Step 1: Paste YAML -->
|
|
<div id="compose-step-paste">
|
|
<div style="margin-bottom: 12px;">
|
|
<label class="form-label-accent-sm">Stack Name</label>
|
|
<input type="text" id="compose-stack-name" placeholder="my-stack" value="" style="width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
|
</div>
|
|
<div style="margin-bottom: 12px;">
|
|
<label class="form-label-accent-sm">docker-compose.yml</label>
|
|
<textarea id="compose-yaml" rows="14" placeholder="version: '3' services: web: image: nginx:latest ports: - '8080:80'" style="width: 100%; padding: 10px; font-family: monospace; font-size: 0.82rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); resize: vertical;"></textarea>
|
|
<div style="margin-top: 6px;">
|
|
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--muted);">
|
|
<input type="file" id="compose-file-upload" accept=".yml,.yaml" style="display: none;" />
|
|
<span style="text-decoration: underline;">or upload a file</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="compose-parse-btn" class="btn-accent-solid" style="padding: 8px 20px;">Parse & Preview</button>
|
|
<button id="compose-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Preview -->
|
|
<div id="compose-step-preview" style="display: none;">
|
|
<div id="compose-preview-content"></div>
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;">
|
|
<button id="compose-deploy-btn" class="btn-accent-solid" style="padding: 8px 20px;">Deploy All</button>
|
|
<button id="compose-back-btn">Back</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Progress -->
|
|
<div id="compose-step-progress" style="display: none;">
|
|
<div id="compose-progress-content"></div>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("compose-import-modal"),S=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(h,A);let k=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}S?.addEventListener("click",()=>{N("paste"),k=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const P=z.target.files[0];if(!P)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(P)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),P=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),T=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:P});k=w,k.stackName=P,R(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{g.textContent=T,g.disabled=!1}});function R(z){const P=document.getElementById("compose-preview-content");let g="";z.networks&&z.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const T of z.services){const w=T.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),g+="</div>",!T.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),T.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(g+=`<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`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}g+="</div>"}g+="</div>",P.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!k)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const P=document.getElementById("compose-progress-content");P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:k.services,networks:k.networks,stackName:k.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const w of g.results){const H=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${H} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(T+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(w.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>',P.innerHTML=T,document.getElementById("compose-done-btn")?.addEventListener("click",()=>{h?.classList.remove("show"),typeof window.loadServices=="function"&&window.loadServices().then(()=>{typeof window.buildGrid=="function"&&window.buildGrid()})}),showNotification(`Stack "${g.stackName}" deployed`,"success")}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Deployment failed: ${escapeHtml(g.message)}</div>
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
|
<h3 id="exec-title">Terminal</h3>
|
|
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 8px; padding-bottom: 12px;">
|
|
<button id="exec-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("exec-modal"),S=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let k=null,N=null,R=null;function z(){if(N){try{N.close()}catch{}N=null}if(k){try{k.dispose()}catch{}k=null}R=null,S.innerHTML=""}function P(g,T){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||g}`,h?.classList.add("show"),typeof Terminal>"u"){S.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}k=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(R=new FitAddon.FitAddon,k.loadAddon(R)),k.open(S),R&&setTimeout(()=>R.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(g)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(k.writeln("\x1B[32mConnecting...\x1B[0m"),R){const E=R.proposeDimensions();E&&N.send(JSON.stringify({type:"resize",cols:E.cols,rows:E.rows}))}},N.onmessage=E=>{if(typeof E.data=="string"){try{const $=JSON.parse(E.data);if($.type==="connected"){k.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r
|
|
`);return}if($.type==="error"){k.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){k.writeln(`\r
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}k.write(E.data)}else k.write(new Uint8Array(E.data))},N.onclose=()=>{k&&k.writeln(`\r
|
|
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{k&&k.writeln(`\r
|
|
\x1B[31mConnection error.\x1B[0m`)},k.onData(E=>{N&&N.readyState===WebSocket.OPEN&&N.send(E)}),k.onResize(({cols:E,rows:$})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:E,rows:$}))});const H=()=>{R&&R.fit()};window.addEventListener("resize",H),h._resizeHandler=H}A?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",g=>{g.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=P})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
|
<h3>\u{1F4DC} Audit Log</h3>
|
|
<p class="modal-subtitle">
|
|
Track all actions performed through the API.
|
|
</p>
|
|
|
|
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
|
<label class="text-muted-sm">Filter:</label>
|
|
<select id="audit-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
|
<option value="">All Actions</option>
|
|
<option value="service">Services</option>
|
|
<option value="container">Containers</option>
|
|
<option value="caddy">Caddy</option>
|
|
<option value="dns">DNS</option>
|
|
<option value="backup">Backups</option>
|
|
<option value="config">Config</option>
|
|
<option value="auth">Auth</option>
|
|
</select>
|
|
<button id="audit-refresh-btn" class="btn-sm">\u{1F504} Refresh</button>
|
|
<span style="flex: 1;"></span>
|
|
<button id="audit-clear-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">\u{1F5D1}\uFE0F Clear Log</button>
|
|
</div>
|
|
|
|
<div id="audit-log-container" class="scroll-container">
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading audit log...</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 12px; text-align: center;">
|
|
<button id="audit-load-more" style="display: none; padding: 6px 16px; font-size: 0.8rem;">Load More</button>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="audit-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("audit-modal"),S=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),k=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),P=document.getElementById("audit-load-more");let g=0;const T=50;async function w(H){try{H||(g=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const E=R.value;let $=`/api/v1/audit-logs?limit=${T}&offset=${g}`;E&&($+=`&action=${encodeURIComponent(E)}`);const L=await(await fetch($)).json(),j=L.success&&L.entries?L.entries:[];if(j.length===0&&!H){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',P.style.display="none";return}let M="";H||(M='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',M+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const O of j){const x=O.outcome==="success";M+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',M+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(O.timestamp)}</td>`,M+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(O.ip||"-")}</td>`,M+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(O.action||"-")}</td>`,M+=`<td style="padding: 6px;">${escapeHtml(O.resource||"-")}</td>`,M+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,M+="</tr>",O.details&&Object.keys(O.details).length>0&&(M+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(O.details,null,2))}</pre></td></tr>`)}if(!H)M+="</table>",z.innerHTML=M;else{const O=z.querySelector("table");O&&O.insertAdjacentHTML("beforeend",M)}g+=j.length,P.style.display=j.length>=T?"":"none",z.querySelectorAll(".audit-row").forEach(O=>{O.dataset.wired||(O.dataset.wired="true",O.addEventListener("click",()=>{const x=O.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(E){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(E.message)}</div>`}}S?.addEventListener("click",()=>{h?.classList.add("show"),w(!1)}),wireModal(h,A),k?.addEventListener("click",()=>w(!1)),R?.addEventListener("change",()=>w(!1)),P?.addEventListener("click",()=>w(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const E=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();E.success?w(!1):showNotification("Error: "+(E.error||"Clear failed"),"error")}catch(H){showNotification("Error: "+H.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
|
|
<h3>\u{1F6E1}\uFE0F Security Center</h3>
|
|
<p class="modal-subtitle">
|
|
Live events from API, Caddy, fail2ban & shared_bans. Live-tail via SSE.
|
|
</p>
|
|
|
|
<div class="sec-tabs" style="display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);">
|
|
<button class="sec-tab active" data-tab="overview">Overview</button>
|
|
<button class="sec-tab" data-tab="events">Events</button>
|
|
<button class="sec-tab" data-tab="hosts">Hosts</button>
|
|
</div>
|
|
|
|
<!-- OVERVIEW TAB -->
|
|
<div class="sec-panel" data-panel="overview">
|
|
<div class="sec-stats" id="sec-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:18px;">
|
|
<div class="sec-stat" data-key="total">\u2014 events</div>
|
|
<div class="sec-stat" data-key="warn">\u2014 warnings</div>
|
|
<div class="sec-stat" data-key="error">\u2014 errors</div>
|
|
<div class="sec-stat" data-key="denied">\u2014 denied</div>
|
|
<div class="sec-stat" data-key="hosts">\u2014 hosts</div>
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
|
|
<div>
|
|
<h4 style="margin:8px 0 6px;">Top Actors (24h)</h4>
|
|
<div id="sec-top-actors" class="scroll-container" style="max-height:240px;">\u2014</div>
|
|
</div>
|
|
<div>
|
|
<h4 style="margin:8px 0 6px;">Top Targets (24h)</h4>
|
|
<div id="sec-top-targets" class="scroll-container" style="max-height:240px;">\u2014</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- EVENTS TAB -->
|
|
<div class="sec-panel" data-panel="events" style="display:none;">
|
|
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
|
|
<select id="sec-filter-source" class="sec-filter">
|
|
<option value="">All sources</option>
|
|
<option value="api">API</option>
|
|
<option value="caddy">Caddy</option>
|
|
<option value="fail2ban">fail2ban</option>
|
|
<option value="shared-bans">shared_bans</option>
|
|
<option value="agent">Agent</option>
|
|
</select>
|
|
<select id="sec-filter-severity" class="sec-filter">
|
|
<option value="">All severities</option>
|
|
<option value="critical">critical</option>
|
|
<option value="error">error</option>
|
|
<option value="warn">warn</option>
|
|
<option value="notice">notice</option>
|
|
<option value="info">info</option>
|
|
</select>
|
|
<select id="sec-filter-host" class="sec-filter">
|
|
<option value="">All hosts</option>
|
|
</select>
|
|
<input id="sec-filter-actor" class="sec-filter" placeholder="actor (IP or user)" style="padding:6px 10px;">
|
|
<button id="sec-refresh-btn" class="btn-sm">\u{1F504} Refresh</button>
|
|
<label style="margin-left:auto;display:flex;align-items:center;gap:6px;">
|
|
<input type="checkbox" id="sec-live-tail" checked>
|
|
<span>Live tail</span>
|
|
</label>
|
|
</div>
|
|
<div id="sec-events-container" class="scroll-container" style="max-height:480px;">Loading\u2026</div>
|
|
</div>
|
|
|
|
<!-- HOSTS TAB -->
|
|
<div class="sec-panel" data-panel="hosts" style="display:none;">
|
|
<div style="display:flex;gap:8px;margin-bottom:12px;">
|
|
<button id="sec-host-register-btn" class="btn-sm">\u2795 Register Host</button>
|
|
<button id="sec-hosts-refresh" class="btn-sm">\u{1F504} Refresh</button>
|
|
</div>
|
|
<div id="sec-hosts-container" class="scroll-container" style="max-height:480px;">Loading\u2026</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="sec-cancel">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("security-modal"),S=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),k=h.querySelectorAll(".sec-tab"),N=h.querySelectorAll(".sec-panel");let R=[],z=[],P=null;k.forEach(s=>{s.addEventListener("click",()=>{k.forEach(l=>l.classList.toggle("active",l===s)),N.forEach(l=>l.style.display=l.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&H(),s.dataset.tab==="events"&&O(),s.dataset.tab==="hosts"&&m()})}),S&&S.addEventListener("click",()=>{h.classList.add("show"),H(),T()}),A.addEventListener("click",g),h.addEventListener("click",s=>{s.target===h&&g()});function g(){h.classList.remove("show"),w()}function T(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{P=new EventSource("/api/v1/security/events/stream"),P.addEventListener("init",s=>{try{R=JSON.parse(s.data).events||[],x()}catch{}}),P.addEventListener("security",s=>{try{const l=JSON.parse(s.data);R.unshift(l),R.length>500&&(R.length=500);const r=h.querySelector(".sec-tab.active")?.dataset?.tab;r==="events"?x():r==="overview"&&H()}catch{}}),P.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function w(){if(P){try{P.close()}catch{}P=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{h.classList.contains("show")&&T()});async function H(){try{const s=new Date(Date.now()-864e5).toISOString(),[l,r,f]=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)}`)]),u=(await l.json()).data||{},t=(await r.json()).data?.hosts||[],e=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${u.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${u.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${u.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,E("sec-top-actors",u.top_actors||[]),E("sec-top-targets",u.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function E(s,l){const r=document.getElementById(s);if(!l.length){r.innerHTML='<div class="panel-empty">No data</div>';return}r.innerHTML='<table style="width:100%;font-size:0.85rem;">'+l.map(f=>`<tr><td style="padding:3px 0;word-break:break-all;">${p(String(f.key))}</td><td style="text-align:right;color:var(--muted);">${f.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),I=document.getElementById("sec-filter-severity"),L=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),M=document.getElementById("sec-refresh-btn");[$,I,L].forEach(s=>s.addEventListener("change",O)),j.addEventListener("input",v(O,250)),M.addEventListener("click",O);async function O(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),I.value&&s.set("severity",I.value),L.value&&s.set("source_host",L.value),j.value&&s.set("actor_prefix",j.value),R=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],x(),(!L.options.length||L.options.length===1)&&await y()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}function x(){const s=document.getElementById("sec-events-container");if(!R.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=R.slice(0,200).map(D).join("")}function D(s){const l=s.severity||"info",r={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[l]||"#7f8c8d",f=s.ts?new Date(s.ts).toLocaleTimeString():"",u=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",o=s.action||"",a=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:${r};font-weight:600;">${p(l)}</span>
|
|
<span style="color:var(--muted);font-size:0.75rem;">${p(u)}</span>
|
|
<span title="${p(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(t)}</span>
|
|
<span title="${p(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(o)} ${p(e)}</span>
|
|
<span style="color:var(--muted);font-size:0.75rem;">${p(a)}</span>
|
|
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${p(f)}</span>
|
|
</div>`}async function y(){try{const l=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],r=L.value;L.innerHTML='<option value="">All hosts</option>'+l.map(f=>`<option value="${p(f.id)}">${p(f.label||f.id)}</option>`).join(""),r&&(L.value=r)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",b),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const l=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=l;const r=document.getElementById("sec-hosts-container");if(!l.length){r.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}r.innerHTML=l.map(f=>{const u=f.enabled?f.last_seen_at?Date.now()-Date.parse(f.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
|
<div>
|
|
<strong>${p(f.label||f.id)}</strong>
|
|
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${p(f.type)}</span>
|
|
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
|
|
id: ${p(f.id)} \xB7
|
|
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7
|
|
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span style="font-size:0.85rem;margin-right:12px;">${u}</span>
|
|
${f.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${p(f.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
|
</div>
|
|
</div>`}).join(""),r.querySelectorAll(".sec-host-del").forEach(f=>{f.addEventListener("click",async()=>{confirm(`Remove host ${f.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(f.dataset.id)}`,{method:"DELETE"}),m())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}async function b(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const l=prompt("Display label:",s)||s,r=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const f=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:l,type:r})}),u=await f.json();if(!f.ok){alert("Failed: "+(u?.error?.message||f.statusText));return}alert(`\u2705 Host registered!
|
|
|
|
id: ${u.data.host.id}
|
|
label: ${u.data.host.label}
|
|
type: ${u.data.host.type}
|
|
|
|
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
|
|
|
|
${u.data.api_key}
|
|
|
|
Send this key as: Authorization: Bearer <api_key>
|
|
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f){alert("Failed: "+f.message)}}function p(s){return String(s).replace(/[&<>"']/g,l=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[l])}function v(s,l){let r;return function(){clearTimeout(r),r=setTimeout(()=>s.apply(this,arguments),l)}}})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
<label for="weather-location-input">Location:</label>
|
|
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
|
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
|
<div style="margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Units</label>
|
|
<div style="display: flex; gap: 8px;">
|
|
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="imperial"><span class="weather-unit-card">°F / mph</span></label>
|
|
<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 S="weather-location",A="weather-zip",k="weather-geo",N="weather-unit";!safeGet(S)&&safeGet(A)&&safeSet(S,safeGet(A));function R(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const P={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(M){return T[Math.round(M/22.5)%16]}async function H(M){const O=safeGet(k);if(O)try{const b=JSON.parse(O);if(b.query===M)return b}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(M)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const D=await x.json();if(!D.results||!D.results.length)throw new Error("Location not found");const y=D.results[0],m={query:M,lat:y.latitude,lon:y.longitude,city:y.name,state:y.admin1||"",country:y.country||"",countryCode:y.country_code||""};return safeSet(k,JSON.stringify(m)),m}function E(M){return M.countryCode==="US"&&M.state?`${M.city}, ${M.state}`:M.country?`${M.city}, ${M.country}`:M.city}async function $(M){try{const O=await H(M),x=R(),D=x==="metric"?"celsius":"fahrenheit",y=x==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${O.lat}&longitude=${O.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${y}`,b=await fetch(m);if(!b.ok)throw new Error("Weather fetch failed");const v=(await b.json()).current,s=v.weather_code;return{temp:Math.round(v.temperature_2m),condition:P[s]||"Unknown",icon:g[s]||"\u{1F324}\uFE0F",locationStr:E(O),windSpeed:Math.round(v.wind_speed_10m),windDir:w(v.wind_direction_10m),unit:x}}catch(O){return console.warn("Weather fetch failed:",O),null}}async function I(){const M=z();if(!M.icon||!M.temp||!M.condition||!M.location||!M.wind){console.warn("Weather widget elements not found");return}const O=safeGet(S);if(!O){M.location.textContent="Set Location",M.temp.textContent="--\xB0",M.condition.textContent="Click \u2699\uFE0F to configure",M.wind.textContent="--",M.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await $(O);if(x){const D=x.unit==="metric"?"\xB0C":"\xB0F",y=x.unit==="metric"?"km/h":"mph";M.location.textContent=x.locationStr,M.temp.textContent=`${x.temp}${D}`,M.condition.textContent=x.condition,M.wind.textContent=`Wind: ${x.windSpeed} ${y} ${x.windDir}`,M.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),M.location.textContent="Weather Error",M.temp.textContent="Error",M.condition.textContent="Failed to load",M.wind.textContent="--"}}const L=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(S)||"";const M=R(),O=L.querySelector(`input[name="weather-unit-radio"][value="${M}"]`);O&&(O.checked=!0),L.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const M=j.value.trim();if(M){safeGet(S)!==M&&safeSet(k,""),safeSet(S,M);const x=L.querySelector('input[name="weather-unit-radio"]:checked'),D=x?x.value:"imperial",y=R();safeSet(N,D),y!==D&&safeSet(k,""),L.classList.remove("show"),I()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(L),document.addEventListener("keydown",M=>{M.key==="Escape"&&L.classList.contains("show")&&L.classList.remove("show")}),I(),setInterval(I,DC.POLL.WEATHER)})(),(function(){const h=document.getElementById("clock-widget"),S=document.getElementById("clock-render");if(!h||!S)return;const A=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],k=["January","February","March","April","May","June","July","August","September","October","November","December"],N=["XII","I","II","III","IV","V","VI","VII","VIII","IX","X","XI"];let R=safeGet("clock-style")||"default",z=-1,P=!1,g="",T="",w=null,H=null;function E(t){if(P||safeGet("clock-chimes")!=="true")return;P=!0;const e=parseInt(safeGet("clock-chime-volume")||"50",10)/100;let o=0;function a(){if(o>=t){P=!1;return}const d=new Audio("/assets/sounds/church-bell.mp3");d.volume=e,d.play().catch(()=>{}),o++,o<t?setTimeout(a,2500):setTimeout(()=>{P=!1},2500)}a()}function $(t){return A[t.getDay()]+", "+k[t.getMonth()]+" "+t.getDate()+", "+t.getFullYear()}function I(){T="",w=null}function L(){return T!=="digital"&&(S.innerHTML='<div class="clock-time"><span class="clock-main"></span><span class="clock-seconds"></span><span class="clock-ampm"></span></div><div class="clock-date"></div>',w={main:S.querySelector(".clock-main"),seconds:S.querySelector(".clock-seconds"),ampm:S.querySelector(".clock-ampm"),date:S.querySelector(".clock-date")},T="digital"),w}function j(t){const e=t.getHours(),o=t.getMinutes(),a=t.getSeconds(),d=e>=12?"PM":"AM",n=e%12||12,i=L();i.main.textContent=`${n}:${String(o).padStart(2,"0")}`,i.seconds.textContent=`:${String(a).padStart(2,"0")}`,i.ampm.textContent=d,i.date.textContent=$(t)}function M(t,e){const o=t.getHours(),a=t.getMinutes(),d=t.getSeconds(),n=o>=12?"PM":"AM",i=o%12||12,c=L();c.main.textContent=`${String(i).padStart(2,"0")}:${String(a).padStart(2,"0")}`,c.seconds.textContent=`:${String(d).padStart(2,"0")}`,c.ampm.textContent=n,c.date.textContent=$(t)}function O(t){const e=t.getHours(),o=t.getMinutes(),a=t.getSeconds(),d=e>=12?"PM":"AM",n=e%12||12,i=String(n).padStart(2," ")+String(o).padStart(2,"0")+String(a).padStart(2,"0");let c='<div class="flip-clock-row">';if(c+=x(i[0],0),c+=x(i[1],1),c+='<span class="flip-colon">:</span>',c+=x(i[2],2),c+=x(i[3],3),c+='<span class="flip-colon">:</span>',c+=x(i[4],4),c+=x(i[5],5),c+=`<span class="flip-ampm">${d}</span>`,c+="</div>",c+=`<div class="clock-date">${$(t)}</div>`,S.innerHTML=c,T="flip",g){for(let C=0;C<6;C++)if(i[C]!==g[C]){const B=S.querySelector(`.flip-card[data-idx="${C}"]`);B&&B.classList.add("flipping")}}g=i}function x(t,e){const o=t===" "?"":t;return`<div class="flip-card" data-idx="${e}"><div class="flip-top">${o}</div><div class="flip-bottom">${o}</div></div>`}function D(t){const e=t.getHours(),o=t.getMinutes(),a=t.getSeconds(),d=e%12||12,n=e>=12?"PM":"AM",i=[Math.floor(d/10),d%10,Math.floor(o/10),o%10,Math.floor(a/10),a%10];let c='<div class="binary-clock">';c+='<div class="binary-labels"><span>H</span><span>H</span><span>M</span><span>M</span><span>S</span><span>S</span></div>';for(let C=3;C>=0;C--){c+='<div class="binary-row">';for(let B=0;B<6;B++){const F=i[B]>>C&1;c+=`<div class="binary-dot ${F?"on":""}"></div>`}c+="</div>"}c+='<div class="binary-values">';for(let C=0;C<6;C++)c+=`<span>${i[C]}</span>`;c+="</div>",c+=`<div class="binary-ampm">${n}</div>`,c+="</div>",c+=`<div class="clock-date">${$(t)}</div>`,S.innerHTML=c,T="binary"}function y(t,e){const o=t.getHours(),a=t.getMinutes(),d=t.getSeconds(),n=120,i=n/2,c=n/2,C=d/60*360-90,B=(a+d/60)/60*360-90,F=(o%12+a/60)/12*360-90;let q="";for(let X=1;X<=12;X++){const Q=X/12*2*Math.PI-Math.PI/2,ne=47,oe=i+ne*Math.cos(Q),se=c+ne*Math.sin(Q),Y=e?N[X%12]:X;q+=`<text x="${oe}" y="${se}" text-anchor="middle" dominant-baseline="central" fill="var(--fg)" font-size="${e?"7":"9"}" font-weight="600" font-family="'Sami Grotesk',sans-serif">${Y}</text>`}let U="";for(let X=0;X<60;X++){const Q=X/60*2*Math.PI-Math.PI/2,ne=56,oe=X%5===0?52:54,se=i+oe*Math.cos(Q),Y=c+oe*Math.sin(Q),ie=i+ne*Math.cos(Q),re=c+ne*Math.sin(Q),ae=X%5===0?1.5:.5;U+=`<line x1="${se}" y1="${Y}" x2="${ie}" y2="${re}" stroke="var(--muted)" stroke-width="${ae}" stroke-linecap="round"/>`}const G=`<svg class="analog-clock-svg" viewBox="0 0 ${n} ${n}" width="${n}" height="${n}">
|
|
<circle cx="${i}" cy="${c}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
${U}
|
|
${q}
|
|
<line x1="${i}" y1="${c}" x2="${i+28*Math.cos(F*Math.PI/180)}" y2="${c+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
<line x1="${i}" y1="${c}" x2="${i+38*Math.cos(B*Math.PI/180)}" y2="${c+38*Math.sin(B*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
<line x1="${i}" y1="${c}" x2="${i+42*Math.cos(C*Math.PI/180)}" y2="${c+42*Math.sin(C*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
<circle cx="${i}" cy="${c}" r="3" fill="var(--fg)"/>
|
|
</svg>`,J=t.getHours()>=12?"PM":"AM";S.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(t)}</span></div></div>`,T="analog"}function m(){const t=new Date,e=t.getHours()%12||12,o=t.getMinutes(),a=t.getSeconds(),d="clock-widget"+(R!=="default"?" "+R:"");switch(h.className!==d&&(h.className=d),R){case"lcd":M(t);break;case"lcd-blue":M(t);break;case"lcd-amber":M(t);break;case"lcd-retro":M(t);break;case"lcd-taxi":M(t);break;case"flip":O(t);break;case"binary":D(t);break;case"analog":y(t,!1);break;case"roman":y(t,!0);break;default:j(t)}o===0&&a===0&&e!==z&&(z=e,E(e)),o!==0&&(z=-1)}function b(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{m(),b()},e)}document.addEventListener("visibilitychange",()=>{g="",I(),m(),b()}),m(),b();const p=[{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 v='<div class="clock-style-grid">';p.forEach(t=>{v+=`<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>`}),v+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="max-width: 420px;">
|
|
<h3>Clock Settings</h3>
|
|
<div style="margin-bottom: 16px;">
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
|
|
${v}
|
|
</div>
|
|
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
|
|
<input type="checkbox" id="clock-chimes-toggle"> Hourly church bell chimes
|
|
</label>
|
|
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px; margin-left: 24px;">
|
|
Strikes the number of the hour (e.g., 3 bells at 3:00)
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom: 16px;" id="clock-volume-section">
|
|
<label style="display: block; margin-bottom: 6px; font-weight: 600; color: var(--fg);">Volume</label>
|
|
<div style="display: flex; align-items: center; gap: 10px;">
|
|
<span style="font-size: 0.85rem;">\u{1F508}</span>
|
|
<input type="range" id="clock-chime-volume" min="0" max="100" value="50" style="flex: 1; accent-color: var(--ok-fg);">
|
|
<span style="font-size: 0.85rem;">\u{1F50A}</span>
|
|
<button id="clock-chime-test" style="padding: 4px 10px; font-size: 0.78rem; border-radius: 4px; cursor: pointer;">Test</button>
|
|
</div>
|
|
</div>
|
|
<div class="weather-modal-buttons">
|
|
<button id="clock-settings-cancel">Cancel</button>
|
|
<button id="clock-settings-save">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const s=document.getElementById("clock-settings-modal"),l=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),f=document.getElementById("clock-volume-section");function u(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),l.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",f.style.opacity=l.checked?"1":"0.4"}l?.addEventListener("change",()=>{f.style.opacity=l.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(r.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(l.checked)),safeSet("clock-chime-volume",r.value),R=e,g="",I(),m(),b(),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",()=>{R=t.value,g="",I(),m()})})})(),(function(){async function h(){try{const P=await(await fetch("/api/v1/health-checks/status")).json();if(!P.success||!P.status)return;for(const[g,T]of Object.entries(P.status)){const w=document.getElementById("uptime-"+g),H=document.getElementById("uptime-bar-"+g);if(!w)continue;const E=T.uptime?.["24h"];if(E!=null){const $=E.toFixed(1);w.textContent=`${$}% uptime`,w.className="uptime-chip",E>=99.9?w.classList.add("excellent"):E>=99?w.classList.add("good"):E>=95?w.classList.add("degraded"):w.classList.add("poor"),H&&(H.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let S;try{S=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{S=new Set}let A=[];async function k(z){try{const g=await(await fetch("/api/v1/updates/available")).json();if(!g.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const T=g.updates||[];if(A=T,z&&T.length>0){const w=window._lastKnownUpdateCount||0;w>0&&T.length>w&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const w of T){const H=window.APPS||[];for(const E of H)if(E.containerId===w.containerId||E.id===w.containerName||E.name===w.containerName){if(S.has(E.id))break;const $=document.getElementById("update-badge-"+E.id),I=document.getElementById("update-btn-"+E.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=L=>{L.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(E.id)}),I&&(I.style.background="#f97316",I.style.borderColor="#f97316",I.style.boxShadow="0 0 6px #f9731688",I.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),k()},5e3),setInterval(()=>{h(),k(!0)},6e4)}const R=window.refreshAll;R&&(window.refreshAll=async function(){try{await R(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,S=null,A={},k={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],R=document.getElementById("theme");if(!R)return;var z=document.getElementById("theme-label");function P(a){if(k[a])return k[a];var d=safeGetJSON(window.USER_THEMES_KEY,{});return d[a]&&d[a].name||a}function g(){z&&(z.textContent=P(window.getActiveTheme()))}R.addEventListener("click",function(){var a=window.THEMES.slice(),d=window.getActiveTheme(),n=a.indexOf(d),i=a[(n+1)%a.length];window.applyTheme(i),g()}),g();function T(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},d={};N.forEach(function(i){d[i[2]]||(d[i[2]]=[]),d[i[2]].push(i)});var n="";return Object.keys(a).forEach(function(i){i==="advanced"?(n+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors ▼</div>',n+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):n+='<div class="theme-builder-section">',n+='<div class="theme-builder-section-title">'+a[i]+"</div>",(d[i]||[]).forEach(function(c){n+='<div class="theme-builder-row"><span class="theme-builder-label">'+c[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+c[0]+'"'+(i==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+c[0]+'">#000000</span></div>'}),n+="</div>"}),n}function w(){return window.THEMES.map(function(a){return'<option value="'+a+'">'+P(a)+"</option>"}).join("")}var H='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+w()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</span><span class="preview-badge" id="preview-badge-bad">OFF</span></div><div class="preview-dots"><span><span class="preview-dot" id="preview-dot-ok"></span> Online</span><span><span class="preview-dot" id="preview-dot-bad"></span> Offline</span></div><button class="preview-btn" id="preview-accent-btn">Accent Button</button></div></div>'+T()+'<div class="weather-modal-buttons"><button id="theme-builder-cancel">Cancel</button><button id="theme-builder-import" style="margin-left:auto;" title="Import theme from JSON file">Import</button><button id="theme-builder-export" title="Export theme as JSON file">Export</button><button id="theme-builder-save" class="btn-accent">Save Theme</button></div></div></div>';injectModal("theme-builder-modal",H);var E=document.getElementById("theme-builder-modal"),$=document.getElementById("theme-builder-start"),I=document.getElementById("theme-builder-name"),L=document.getElementById("theme-builder-edit-select"),j=document.getElementById("theme-builder-existing"),M=document.getElementById("theme-builder-lightbg"),O=E.querySelectorAll(".theme-builder-color"),x=document.getElementById("theme-builder-advanced"),D=document.getElementById("theme-builder-advanced-toggle"),y=document.getElementById("theme-builder-export"),m=!1;D.addEventListener("click",function(){m=!m,x.style.display=m?"":"none",D.innerHTML=m?"Hide advanced colors ▲":"Show advanced colors ▼"});function b(){var a={};return O.forEach(function(d){a[d.dataset.prop]=d.value}),a["card-bg"]=a["card-base"],a}function p(){var a={};return O.forEach(function(d){d.dataset.advanced||(a[d.dataset.prop]=d.value)}),a["card-bg"]=a["card-base"],M.checked&&(a.lightBg=!0),a}function v(){var a=p(),d=window.deriveExtendedColors?window.deriveExtendedColors(a):{};O.forEach(function(n){if(n.dataset.advanced&&!A[n.dataset.prop]){var i=d[n.dataset.prop]||"#333333";n.value=i;var c=E.querySelector('[data-hex="'+n.dataset.prop+'"]');c&&(c.textContent=i.toUpperCase())}})}function s(a){var d=window.THEME_COLORS[a];d&&(A={},O.forEach(function(n){var i=d[n.dataset.prop]||"#000000";(i.startsWith("rgba")||i.startsWith("color-mix"))&&(i="#333333"),n.value=i;var c=E.querySelector('[data-hex="'+n.dataset.prop+'"]');c&&(c.textContent=i.toUpperCase())}),M.checked=!!d.lightBg,r())}function l(a){var d=safeGetJSON(window.USER_THEMES_KEY,{}),n=d[a];n&&(A={},O.forEach(function(i){var c=n[i.dataset.prop]||"#000000";i.value=c;var C=E.querySelector('[data-hex="'+i.dataset.prop+'"]');C&&(C.textContent=c.toUpperCase()),i.dataset.advanced&&n[i.dataset.prop]&&(A[i.dataset.prop]=!0)}),I.value=n.name||"",M.checked=!!n.lightBg,v(),r())}function r(){var a=b(),d=document.getElementById("theme-builder-preview"),n=document.getElementById("theme-preview-card"),i=n.querySelector(".preview-title"),c=n.querySelector(".preview-muted"),C=document.getElementById("preview-badge-ok"),B=document.getElementById("preview-badge-bad"),F=document.getElementById("preview-dot-ok"),q=document.getElementById("preview-dot-bad"),U=document.getElementById("preview-accent-btn"),G=n.querySelector(".preview-dots");d.style.background=a.bg,n.style.background=a["card-base"],n.style.borderColor=a.border,i.style.color=a.fg,c.style.color=a.muted,C.style.background=a["ok-bg"],C.style.color=a["ok-fg"],B.style.background=a["bad-bg"],B.style.color=a["bad-fg"],F.style.background=a["dot-ok"],q.style.background=a["dot-bad"],G.style.color=a.fg,U.style.background=a.accent,U.style.color=a.bg}function f(a){var d=window.deriveExtendedColors?window.deriveExtendedColors(a):{};window.THEME_PROPS.forEach(function(n){var i=a[n]||d[n];i&&document.documentElement.style.setProperty("--"+n,i)})}O.forEach(function(a){a.addEventListener("input",function(){var d=E.querySelector('[data-hex="'+a.dataset.prop+'"]');d&&(d.textContent=a.value.toUpperCase()),a.dataset.advanced?A[a.dataset.prop]=!0:v(),r(),f(b())})}),M.addEventListener("change",function(){v(),r(),f(b())}),$.addEventListener("change",function(){A={},s($.value),f(b())});function u(){var a=safeGetJSON(window.USER_THEMES_KEY,{}),d=Object.keys(a);L.innerHTML='<option value="">\u2014 New Theme \u2014</option>',d.forEach(function(n){var i=document.createElement("option");i.value=n,i.textContent=a[n].name||n,L.appendChild(i)}),j.style.display=d.length?"":"none"}function t(){$.innerHTML=w()}L.addEventListener("change",function(){var a=this.value;a?(S=a,l(a),f(b())):(S=null,A={},I.value="",$.value=window.getActiveTheme(),s($.value)),y.style.display=S?"":"none"});function e(){h=window.getActiveTheme(),S=null,A={},m=!1,x.style.display="none",D.innerHTML="Show advanced colors ▼",u(),t();var a=safeGetJSON(window.USER_THEMES_KEY,{});a[h]?(L.value=h,S=h,l(h)):(L.value="",I.value="",$.value=h,s(h)),y.style.display=S?"":"none",E.classList.add("show")}var o=document.getElementById("theme-customize-btn");o&&o.addEventListener("click",function(){e()}),document.getElementById("theme-builder-save").addEventListener("click",function(){var a=b(),d=I.value.trim();if(!d){showNotification("Please enter a theme name","warning",3e3),I.focus();return}var n=safeGetJSON(window.USER_THEMES_KEY,{}),i,c=null;if(S){i=S;var C=window.slugifyThemeName(d,S);if(C!==S){c=S,delete n[S];var B=window.THEMES.indexOf(S);B!==-1&&window.THEMES.splice(B,1),delete window.THEME_COLORS[S],i=C}}else i=window.slugifyThemeName(d);var F={name:d};M.checked&&(F.lightBg=!0),window.THEME_PROPS.forEach(function(U){a[U]&&(F[U]=a[U])}),n[i]=F,safeSet(window.USER_THEMES_KEY,JSON.stringify(n)),window.clearCustomProperties(),window.injectUserThemeStyles(),window.applyTheme(i),E.classList.remove("show"),g();var q={};window.THEME_PROPS.forEach(function(U){a[U]&&(q[U]=a[U])}),c&&secureFetch("/api/v1/themes/"+c,{method:"DELETE"}).catch(function(){}),secureFetch("/api/v1/themes/"+i,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:d,colors:q,lightBg:M.checked})}).then(function(){showNotification(d+" theme saved","success",3e3)}).catch(function(){showNotification(d+" theme saved locally (server sync failed)","warning",3e3)})}),document.getElementById("theme-builder-cancel").addEventListener("click",function(){E.classList.remove("show"),window.clearCustomProperties(),h&&window.applyTheme(h)}),document.getElementById("theme-builder-delete").addEventListener("click",function(){if(S){var a=safeGetJSON(window.USER_THEMES_KEY,{}),d=a[S]?a[S].name:S;if(confirm('Delete "'+d+'" theme?')){var n=S;delete a[n],safeSet(window.USER_THEMES_KEY,JSON.stringify(a));var i=window.THEMES.indexOf(n);i!==-1&&window.THEMES.splice(i,1),delete window.THEME_COLORS[n],window.clearCustomProperties(),window.injectUserThemeStyles();var c=h&&h!==n?h:"dark";window.applyTheme(c),S=null,E.classList.remove("show"),g(),secureFetch("/api/v1/themes/"+n,{method:"DELETE"}).then(function(){showNotification(d+" theme deleted","success",3e3)}).catch(function(){showNotification(d+" theme deleted locally (server sync failed)","warning",3e3)})}}}),document.getElementById("theme-builder-export").addEventListener("click",function(){if(!S){showNotification("Save the theme first, then export","warning",3e3);return}var a=safeGetJSON(window.USER_THEMES_KEY,{}),d=a[S];if(d){var n={_dashcaddy_theme:!0,version:"1.0",exportDate:new Date().toISOString(),slug:S,name:d.name,lightBg:d.lightBg||!1,colors:{}};window.THEME_PROPS.forEach(function(B){d[B]&&(n.colors[B]=d[B])});var i=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),c=URL.createObjectURL(i),C=document.createElement("a");C.href=c,C.download=S+"-theme.json",document.body.appendChild(C),C.click(),document.body.removeChild(C),URL.revokeObjectURL(c),showNotification("Theme exported as "+S+"-theme.json","success",3e3)}}),document.getElementById("theme-builder-import").addEventListener("click",function(){var a=document.createElement("input");a.type="file",a.accept=".json",a.onchange=function(d){var n=d.target.files[0];if(n){var i=new FileReader;i.onload=function(c){try{var C=JSON.parse(c.target.result),B,F,q;if(C._dashcaddy_theme&&C.colors)B=C.name||"Imported",F=C.colors,q=C.lightBg||!1;else if(C.name&&(C.bg||C["card-base"]))B=C.name,F={},window.THEME_PROPS.forEach(function(U){C[U]&&(F[U]=C[U])}),q=C.lightBg||!1;else throw new Error("Not a valid DashCaddy theme file");I.value=B,M.checked=!!q,S=null,L.value="",A={},O.forEach(function(U){var G=F[U.dataset.prop]||"#000000";U.value=G;var J=E.querySelector('[data-hex="'+U.dataset.prop+'"]');J&&(J.textContent=G.toUpperCase()),U.dataset.advanced&&F[U.dataset.prop]&&(A[U.dataset.prop]=!0)}),v(),r(),f(b()),y.style.display="none",showNotification('"'+B+'" loaded into builder. Click Save to keep it.',"success",5e3)}catch(U){showNotification("Import failed: "+U.message,"error",5e3)}},i.readAsText(n)}},a.click()}),wireModal(E),E.addEventListener("click",function(a){a.target===E&&(window.clearCustomProperties(),h&&window.applyTheme(h))})})(),(function(){injectModal("license-modal",`<div id="license-modal" class="weather-modal">
|
|
<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;">
|
|
Activate a license code to unlock premium features.
|
|
</p>
|
|
|
|
<div id="license-status-section" style="margin-bottom: 16px;">
|
|
<div id="license-badge" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; margin-bottom: 12px;">
|
|
<span id="license-badge-icon"></span>
|
|
<span id="license-badge-text"></span>
|
|
</div>
|
|
<div id="license-details" style="font-size: 0.85rem; color: var(--muted); line-height: 1.6;"></div>
|
|
</div>
|
|
|
|
<div id="license-activate-section">
|
|
<label class="form-label-bold">License Code:</label>
|
|
<input type="text" id="license-code-input" placeholder="DC-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
|
|
maxlength="35" spellcheck="false" autocomplete="off"
|
|
style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem; font-family: monospace; letter-spacing: 1px;" />
|
|
<p class="tiny-hint">Enter your license code to activate premium features</p>
|
|
</div>
|
|
|
|
<div id="license-features" style="margin-top: 16px;">
|
|
<label class="form-label-bold" style="margin-bottom: 8px; display: block;">Premium Features:</label>
|
|
<div id="license-feature-list" style="display: grid; gap: 8px;"></div>
|
|
</div>
|
|
|
|
<div id="license-error" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(231,76,60,0.15); color: var(--bad-fg); font-size: 0.85rem;"></div>
|
|
<div id="license-success" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(46,204,113,0.15); color: var(--ok-fg); font-size: 0.85rem;"></div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 16px;">
|
|
<button id="license-cancel">Close</button>
|
|
<button id="license-deactivate" class="btn-accent" style="display: none; background: var(--bad-bg); border-color: var(--bad-fg); color: var(--bad-fg);">Deactivate</button>
|
|
<button id="license-activate" class="btn-accent">Activate</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const h=document.getElementById("license-modal"),S=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),k=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),R=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),P=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),T=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let E=null;function $(){N.style.display="none",R.style.display="none"}function I(b){$(),N.textContent=b,N.style.display="block"}function L(b){$(),R.textContent=b,R.style.display="block"}function j(b){if(E=b,b.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",z.textContent="\u2605",P.textContent="Premium Active";const s=b.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(b.expiresAt).toLocaleDateString()}</strong> (${b.daysRemaining} days remaining)</div>`;T.innerHTML=`
|
|
<div>Code: <code style="font-family: monospace;">${b.code||"***"}</code></div>
|
|
${s}
|
|
`,H.style.display="none",A.style.display="none",k.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",z.textContent="\u2606",P.textContent=b.expired?"License Expired":"Free Tier",T.innerHTML=b.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",A.style.display="",k.style.display="none";const p=b.premiumFeatures||{},v=new Set(b.features||[]);w.innerHTML=Object.entries(p).map(([s,l])=>`<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;">${v.has(s)?"\u2705":"\u{1F512}"}</span>
|
|
<div>
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${l.name}</div>
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${l.description}</div>
|
|
</div>
|
|
</div>`).join("")}async function M(){try{const p=await(await fetch("/api/v1/license/status")).json();p.success&&(j(p.license),D(p.license))}catch(b){console.warn("Failed to load license status:",b.message)}}async function O(){const b=S.value.trim();if(!b){I("Please enter a license code.");return}$(),A.disabled=!0,A.textContent="Activating...";try{const v=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();v.success?(L(v.message),S.value="",j(v.license),showNotification("License activated! Premium features unlocked.","success",5e3),D(v.license)):I(v.error||"Activation failed")}catch(p){I("Network error: "+p.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){k.disabled=!0,k.textContent="Deactivating...";try{const p=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();p.success?(L(p.message),await M(),showNotification("License deactivated.","info",3e3),D({active:!1})):I(p.error||"Deactivation failed")}catch(b){I("Network error: "+b.message)}finally{k.disabled=!1,k.textContent="Deactivate"}}}function D(b){const p=document.getElementById("license-status-topbar"),v=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),l=document.getElementById("license-topbar-time");if(p)if(p.className="license-status-topbar "+(b.active?"premium":"free"),b.active)if(v.textContent="\u2605",s.textContent="PREMIUM",b.lifetime)l.textContent="\xB7 LIFETIME";else{const r=b.daysRemaining;l.textContent=r!=null?"\xB7 "+r+"d remaining":""}else v.textContent="\u2606",s.textContent=b.expired?"EXPIRED":"FREE TIER",l.textContent=""}function y(){$(),M(),h.classList.add("show")}S.addEventListener("input",function(){let b=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(b.length>this._prevLength&&(b=b.replace(/-/g,""),b.length>2&&!b.startsWith("DC")&&(b="DC"+b),b.startsWith("DC")&&b.length>2)){const p=["DC"],v=b.substring(2);for(let s=0;s<v.length;s+=5)p.push(v.substring(s,s+5));b=p.join("-")}this._prevLength=b.length,this.value=b}),A.addEventListener("click",O),k.addEventListener("click",x),S.addEventListener("keydown",b=>{b.key==="Enter"&&O()}),wireModal(h,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=y,window.checkPremiumFeature=async function(b){try{return(await(await fetch(`/api/v1/license/feature/${b}`)).json()).available}catch{return!1}},M().then(b=>{E&&D(E)})})();
|