Cloud backups (Dropbox / WebDAV / SFTP):
- backup-manager.js: save + load handlers per provider, credential
resolution via credentialManager, destination probe.
- routes/backups.js: /credentials/{provider} (masked GET, POST, DELETE),
/test-destination, scheduling endpoints.
- status/js/backup-restore.js: destination picker, provider-specific
credential forms, test button wired to backend probe.
- npm deps already present (dropbox 10.34.0, webdav 5.7.1,
ssh2-sftp-client 11.0.0).
Resource history:
- resource-monitor.js: three-tier rollup storage — raw 10s samples
(7-day retention), hourly rollups (30-day), daily rollups
(365-day). getHistoryByRange() auto-selects the appropriate tier.
- routes/monitoring.js: /monitoring/history/:containerId now supports
startTime/endTime range mode (legacy ?hours=N still works).
- status/js/resource-monitor.js + dashboard.css: "History" tab with
range buttons (1h/24h/7d/30d/1y), SVG sparklines for
CPU / memory / network. Renderer handles raw and rolled-up shapes.
status/dist/features.js rebuilt from source via build.js.
Lifted out of wip/cloud-backups-and-history; the half-finished
app-deps feature from that branch (frontend calls /api/v1/apps/
check-dependencies but the endpoint doesn't exist) is preserved
separately on wip/app-deps for later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1569 lines
248 KiB
JavaScript
1569 lines
248 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 w=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),z=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),C=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),$=document.getElementById("logo-single-upload"),L=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),k=document.getElementById("logo-upload-single"),f=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),E=document.querySelector(".top-row"),R=document.getElementById("dashboard-title"),O=DC.NAME;let D=null,N=null,M=null,H="left",S=O;C?.addEventListener("change",()=>{C.checked?(P.style.display="none",$.style.display="",D=null,N=null):(P.style.display="flex",$.style.display="none",M=null)});function T(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=t=>e(t.target.result),a.readAsDataURL(n)}L?.addEventListener("change",n=>{T(n.target.files[0],e=>{D=e,B.src=e,I.textContent="New dark logo ready to save"})}),g?.addEventListener("change",n=>{T(n.target.files[0],e=>{N=e,z.src=e,I.textContent="New light logo ready to save"})}),k?.addEventListener("change",n=>{T(n.target.files[0],e=>{M=e,B.src=e,z.src=e,I.textContent="New logo ready to save (both themes)"})});function b(n){E.setAttribute("data-logo-pos",n),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===n?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===n?"white":"var(--fg)"})}function u(n){S=n||O,document.title=S;const e=document.querySelector(".dashboard-title");e&&(e.textContent=S)}async function y(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(f.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,z.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(f.src=e.customLogo,x.src=e.customLogo,B.src=e.customLogo,z.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(H=e.position,b(e.position)),e.dashboardTitle&&u(e.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{H=n.dataset.pos,b(H)})}),document.getElementById("brand")?.addEventListener("click",()=>{D=null,N=null,M=null,L&&(L.value=""),g&&(g.value=""),k&&(k.value=""),C&&(C.checked=!1),P.style.display="flex",$.style.display="none",B.src=f.src,z.src=x.src;const n=f.src.includes("custom-logo")||x.src.includes("custom-logo");I.textContent=n?"Using custom logo":"Using default logos",b(H),R.value=S,w.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=R.value.trim()||O,e={position:H,dashboardTitle:n};C?.checked&&M?(e.dataDark=M,e.dataLight=M):(D&&(e.dataDark=D),N&&(e.dataLight=N));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const t=await a.json(),s="?t="+Date.now();t.pathDark&&(f.src=t.pathDark+s,B.src=t.pathDark+s),t.pathLight&&(x.src=t.pathLight+s,z.src=t.pathLight+s),b(H),u(n),w.classList.remove("show")}else{const t=await a.json();showNotification("Failed to save: "+t.error,"error")}}catch(n){showNotification("Error saving: "+n.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(f.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",z.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",D=null,N=null,M=null,R.value=O,u(O),H="left",b("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),s=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),s&&(s.textContent="Using DashCaddy favicon"),r=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(w,document.getElementById("logo-cancel"));const c=document.getElementById("favicon-preview"),o=document.getElementById("favicon-status"),l=document.getElementById("favicon-upload"),v=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(v.rel="icon",v.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(v));async function p(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const e=await n.json();e.customFavicon&&(v.href=e.customFavicon+"?t="+Date.now(),c.src=e.customFavicon+"?t="+Date.now(),o.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}l?.addEventListener("change",n=>{const e=n.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),l.value="";return}const a=new FileReader;a.onload=t=>{r=t.target.result,c.src=r,o.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(n.ok){const e=await n.json();v.href=e.path+"?t="+Date.now(),c.src=e.path+"?t="+Date.now(),o.textContent="Using custom favicon",r=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),p(),y();const m=document.getElementById("settings-timezone");m&&(new MutationObserver(()=>{w.classList.contains("show")&&m.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(m,e)})()}).observe(w,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=m.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const t=await a.json();t.timezone=e,t.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(w,B){const z=Intl.supportedValuesOf("timeZone"),I=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";w.innerHTML="";for(const C of z){const P=document.createElement("option");P.value=C,P.textContent=C.replace(/_/g," "),C===I&&(P.selected=!0),w.appendChild(P)}},(function(){let w="homelab",B=null;async function z(){try{const T=await fetch("/api/v1/config");if(T.ok&&(B=await T.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(T){console.warn("Could not fetch server config, checking localStorage fallback:",T.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}z();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function C(S){document.querySelectorAll(".setup-step").forEach(b=>{b.style.display="none"});const T=document.getElementById(S);T&&(T.style.display="block")}function P(){const S=document.getElementById("setup-summary-content");if(!S)return;let T='<div style="display: grid; gap: 20px;">';if(w==="homelab"){const u=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",c=document.getElementById("setup-dns-ip")?.value?.trim()||"",o=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;T+=`
|
|
<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> ${u}</div>
|
|
<div><strong>Certificate Authority:</strong> ${y}</div>
|
|
<div><strong>DNS Server:</strong> ${c}:${o}</div>
|
|
<div><strong>Example URLs:</strong> https://uptime${u}, https://nextcloud${u}</div>
|
|
</div>
|
|
</div>
|
|
`}else if(w==="simple"){const u=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";T+=`
|
|
<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> ${u}</div>
|
|
<div><strong>SSL:</strong> None (HTTP only)</div>
|
|
<div><strong>Example URLs:</strong> http://${u}:8080, http://${u}:3000</div>
|
|
</div>
|
|
</div>
|
|
`}else if(w==="public"){const u=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",c=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",o=c==="subdirectory"?`https://${u}/sonarr, https://${u}/grafana`:`https://sonarr.${u}, https://grafana.${u}`;T+=`
|
|
<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> ${u}</div>
|
|
<div><strong>SSL:</strong> Let's Encrypt</div>
|
|
<div><strong>Email:</strong> ${y}</div>
|
|
<div><strong>Routing:</strong> ${c==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
<div><strong>Example URLs:</strong> ${o}</div>
|
|
</div>
|
|
</div>
|
|
`}const b=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";T+=`
|
|
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${b.replace(/_/g," ")}</div>
|
|
</div>
|
|
`,T+="</div>",S.innerHTML=T,C("setup-step-summary")}async function $(S){try{const T=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)});return T.ok?(await T.json(),!0):(console.error("Failed to save config to server:",T.status),!1)}catch(T){return console.error("Error saving config to server:",T),!1}}async function L(){const S={setupComplete:!0,configurationType:w,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};w==="homelab"?(S.tld=document.getElementById("setup-tld")?.value?.trim()||".home",S.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",S.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},S.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):w==="simple"?(S.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",S.defaults={dnsType:"none",sslType:"none",targetIP:S.defaultIP}):w==="public"&&(S.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",S.email=document.getElementById("setup-public-email")?.value?.trim()||"",S.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",S.defaults={dnsType:S.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const T=await $(S);safeSet("dashcaddy-config",JSON.stringify(S)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const b=w==="homelab"?"Professional Home Lab":w==="simple"?"Simple Setup":"Public Server",u=T?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${b}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(S){S.preventDefault();const T=document.querySelector('input[name="config-type"]:checked');T&&(w=T.value),C(w==="homelab"?"setup-step-homelab":w==="simple"?"setup-step-simple":w==="public"?"setup-step-public":"setup-step-homelab")});const k=document.getElementById("setup-skip");k&&(k.onclick=async function(S){S.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await $({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const f=document.getElementById("setup-tld");f&&(f.oninput=function(S){const T=S.target.value||".home",b=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");b&&(b.textContent=T),u&&(u.textContent=T)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(S){S.preventDefault(),C("setup-step-1")});const E=document.getElementById("setup-homelab-next");E&&(E.onclick=function(S){S.preventDefault();const T=document.getElementById("setup-tld")?.value?.trim()||"",b=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!T||!T.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!b){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}P()});const R=document.getElementById("setup-simple-back");R&&(R.onclick=function(S){S.preventDefault(),C("setup-step-1")});const O=document.getElementById("setup-simple-next");O&&(O.onclick=function(S){S.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(S){S.onchange=function(){var T=document.getElementById("dns-requirement-note");T&&(T.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const D=document.getElementById("setup-public-back");D&&(D.onclick=function(S){S.preventDefault(),C("setup-step-1")});const N=document.getElementById("setup-public-next");N&&(N.onclick=function(S){S.preventDefault();const T=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"";if(!T){showNotification("Please enter your domain name","warning");return}if(!b||!b.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(S){S.preventDefault(),w==="homelab"?C("setup-step-homelab"):w==="simple"?C("setup-step-simple"):w==="public"&&C("setup-step-public")});const H=document.getElementById("setup-finish");H&&(H.onclick=function(S){S.preventDefault(),L()}),window.getGlobalConfig=async function(){try{const T=await fetch("/api/v1/config");if(T.ok){const b=await T.json();if(b&&b.setupComplete)return b}}catch{console.warn("Could not fetch config from server")}const S=safeGet("dashcaddy-config");return S?JSON.parse(S):{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(){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 w="custom-apps";let B=null,z=null;const I=document.getElementById("app-selector-modal"),C=document.getElementById("app-selector-grid");async function P(){try{const l=await(await fetch("/api/v1/apps/templates")).json();if(l.success)return B=l.templates,z=l.categories,!0}catch(o){console.error("Failed to fetch app templates:",o)}return!1}async function $(o){try{return await(await fetch(`/api/v1/apps/ports/${o}/check`)).json()}catch(l){return console.error("Failed to check port:",l),{available:!0}}}async function L(o){try{const v=await(await fetch(`/api/v1/apps/ports/${o}/suggest`)).json();if(v.success)return v.suggestedPort}catch(l){console.error("Failed to get suggested port:",l)}return o}async function g(){if(C.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!B&&!await P()){C.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}C.innerHTML="";const o={};for(const[v,r]of Object.entries(B)){const p=r.category||"Other";o[p]||(o[p]=[]),o[p].push({id:v,...r})}const l=z?Object.keys(z):Object.keys(o).sort();for(const v of l){const r=o[v];if(!r||r.length===0)continue;r.sort((n,e)=>(e.popularity||0)-(n.popularity||0));const p=document.createElement("div");p.className="app-category-header";const m=z?.[v]||{};p.innerHTML=`${escapeHtml(m.icon||"")} ${escapeHtml(v)}`,m.color&&(p.style.borderBottomColor=m.color),C.appendChild(p),r.forEach(n=>{const e=document.createElement("div");e.className="app-option";const a=n.isDashboardWidget,t=a&&safeGet("widget-"+n.id+"-enabled")!=="false",s=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${t?"#2ecc7130":"#e74c3c30"}; color: ${t?"#2ecc71":"#e74c3c"}; font-weight: 600;">${t?"ON":"OFF"}</div>`:"",i=!a&&n.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${n.difficulty==="Easy"?"#2ecc71":n.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${n.difficulty==="Easy"?"#2ecc71":n.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(n.difficulty)}</div>`:"";e.innerHTML=`
|
|
<div class="app-option-icon">${escapeHtml(n.icon||"\u{1F4E6}")}</div>
|
|
<div class="app-option-name">${escapeHtml(n.name)}</div>
|
|
<div class="app-option-desc">${escapeHtml(n.description||"")}</div>
|
|
${s}${i}
|
|
`,a?e.onclick=()=>k(n,e):e.onclick=()=>f(n),C.appendChild(e)})}window.renderRecipeCards&&await window.renderRecipeCards(C)}function k(o,l){const v="widget-"+o.id+"-enabled",p=!(safeGet(v)!=="false");safeSet(v,String(p));const m=o.widgetSelector;if(m){const e=document.querySelector(m);e&&(e.style.display=p?"":"none")}const n=l.querySelector('div[style*="border-radius: 4px"]');n&&(n.textContent=p?"ON":"OFF",n.style.background=p?"#2ecc7130":"#e74c3c30",n.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${o.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function f(o){const l=document.getElementById("app-deploy-modal"),v=document.getElementById("app-deploy-title"),r=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),m=document.getElementById("deploy-ip"),n=document.getElementById("deploy-port"),e=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const J=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:o.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${o.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.`)&&(o._useExisting=!0,o._existingContainer=V)}}catch{}v.textContent=`Deploy ${o.name}`;const t=o.subdomain||o.id.replace(/-/g,"");r.value=t;const s=document.getElementById("subpath-compat-warning");if(s)if(SITE.routingMode==="subdirectory"){const q=o.subpathSupport||"strip";q==="none"?(s.style.display="block",s.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+o.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):q==="strip"?(s.style.display="block",s.innerHTML='<span style="color: var(--muted);">ⓘ '+o.name+" has unverified subdirectory support. It may require additional configuration.</span>"):s.style.display="none"}else s.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),h=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),d=document.querySelector(`input[name="dns-type"][value="${i}"]`),A=document.querySelector(`input[name="ssl-type"][value="${h}"]`);d?d.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,A?A.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,m.value=SITE.defaults.targetIP||"localhost",e.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),j=document.querySelector("#app-deploy-modal details"),_=j?.querySelector("div");if(j&&_&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const q=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,J=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;q&&!q.dataset.moved&&(_.appendChild(q),q.dataset.moved="1"),J&&!J.dataset.moved&&(_.appendChild(J),J.dataset.moved="1")}const U=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),Z=document.getElementById("media-path-description");if(o.mediaMount){U.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const q=document.getElementById("detected-mounts-container"),J=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){q.style.display="block",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.value=te.join(", "),K.mounts.forEach(ee=>{const Y=document.createElement("button");Y.type="button";const le=te.includes(ee.hostPath);Y.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);`,Y.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>`,Y.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Y.onclick=()=>{const re=W.value.split(",").map(de=>de.trim()).filter(de=>de),ce=re.indexOf(ee.hostPath);ce>=0?(re.splice(ce,1),Y.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(re.push(ee.hostPath),Y.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),W.value=re.join(", ")},J.appendChild(Y)})}else q.style.display="none"}catch{q.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else U.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const G=document.getElementById("plex-claim-section");G&&(o.id==="plex"||o.claimToken?(G.style.display="block",document.getElementById("deploy-plex-claim").value=""):G.style.display="none");const Q=document.getElementById("volume-mounts-section"),ae=document.getElementById("volume-mounts-list");if(ae.innerHTML="",o.docker?.volumes?.length){const q=o.mediaMount?.containerPath,J=o.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(q&&V.endsWith(":"+q)));J.length>0?(Q.style.display="block",J.forEach((V,K)=>{const[te,ee]=V.split(":"),Y=document.createElement("div");Y.style.cssText="display: flex; gap: 6px; align-items: center;",Y.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>
|
|
`,ae.appendChild(Y),Y.querySelector(".vol-browse-btn").onclick=()=>{const le=Y.querySelector(".vol-host-path");openFolderBrowser(le)}})):Q.style.display="none"}else Q.style.display="none";const ne=o.defaultPort||8080;n.value="",n.placeholder=`Default: ${ne}`;let X=document.getElementById("deploy-port-status");X||(X=document.createElement("div"),X.id="deploy-port-status",X.style.cssText="font-size: 0.8rem; margin-top: 4px;",n.parentNode.appendChild(X));async function se(){const q=n.value||ne;X.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const J=await $(q);if(J.available)X.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(q))} is available</span>`;else{const V=await L(ne);X.innerHTML=`
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(q)} in use by ${escapeHtml(J.conflict?.usedBy||"unknown")}</span>
|
|
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,X.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},X.appendChild(K)}}let ie;n.oninput=function(){clearTimeout(ie),ie=setTimeout(se,500)},se();try{const J=await(await fetch("/api/v1/tailscale/status")).json();J.success&&J.installed&&J.connected?a.innerHTML=`
|
|
<span style="color: #4caf50;">Connected</span>
|
|
<span style="color: var(--muted); margin-left: 8px;">${J.self?.hostname} (${J.self?.ip})</span>
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${J.deviceCount} devices</span>
|
|
`:J.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',e.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function oe(){const q=r.value||"subdomain",J=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${q}`;else if(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(q)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||q;K=SITE.domain?`${te}://${q}.${SITE.domain}`:`${te}://${q}`}else{const te=n.value||o.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${m.value}:${te}`}p.textContent=K}r.oninput=oe,m.oninput=oe,n.oninput=oe,document.querySelectorAll('input[name="dns-type"]').forEach(q=>{q.onchange=oe}),document.querySelectorAll('input[name="ssl-type"]').forEach(q=>{q.onchange=oe}),oe(),I.classList.remove("show"),l.classList.add("show"),l.dataset.appTemplate=JSON.stringify(o)}async function x(o){const l=o.appTemplate,v=safeGetJSON(w,[]),r=l._useExisting&&l._existingContainer,p=v.find(m=>m.id===o.subdomain);if(!(p&&!r&&!confirm(`An app with subdomain "${o.subdomain}" already exists. Redeploy?`))){if(p){const m=v.indexOf(p);v.splice(m,1),safeSet(w,JSON.stringify(v))}if(r)o.port=l._existingContainer.primaryPort;else{const m=o.port||l.defaultPort||8080;showNotification(`Checking port ${m} availability...`,"info",0);const n=await $(m);if(!n.available){const e=await L(l.defaultPort||8080);if(confirm(`Port ${m} is already in use by ${n.conflict?.usedBy||"another container"}.
|
|
|
|
Would you like to use port ${e} instead?`))o.port=e;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(r?`Configuring ${l.name} with existing container...`:`Deploying ${l.name}...`,"info",0);try{const m={appId:l.id,config:{subdomain:o.subdomain,ip:o.ip,createDns:o.dnsType==="private",port:o.port||l.defaultPort||null,sslType:o.sslType,dnsType:o.dnsType,tailscaleOnly:o.tailscaleOnly||!1,mediaPath:o.mediaPath||null,plexClaimToken:o.plexClaimToken||null,customVolumes:o.customVolumes||null}};r&&(m.config.useExisting=!0,m.config.existingContainerId=l._existingContainer.id,m.config.existingPort=l._existingContainer.primaryPort,!o.port&&l._existingContainer.primaryPort&&(m.config.port=l._existingContainer.primaryPort));const e=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)})).json();if(e.success){const a={id:o.subdomain,name:l.name,logo:`/assets/${l.id}.png`,containerId:e.containerId,url:e.url,ip:o.ip,appTemplate:l.id,tailscaleOnly:o.tailscaleOnly||!1};v.push(a),safeSet(w,JSON.stringify(v)),window.APPS&&!window.APPS.some(s=>s.id===l.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let t=e.usedExisting?`${l.name} configured with existing container!
|
|
URL: ${e.url}`:`${l.name} deployed successfully!
|
|
URL: ${e.url}`;e.warning&&(t+=`
|
|
|
|
\u26A0 Warning: ${e.warning}`),showNotification(t,"success",8e3),delete l._useExisting,delete l._existingContainer,e.url&&e.url.startsWith("https://")&&E(e.url,l.name),e.setupInstructions&&e.setupInstructions.length>0&&setTimeout(()=>{const s=e.setupInstructions.join(`
|
|
`);showNotification(`Setup Instructions for ${l.name}: ${s}`,"info",1e4)},1e3)}else throw new Error(e.error||"Deployment failed")}catch(m){console.error("Deployment error:",m),showNotification(`Failed to deploy ${l.name}: ${m.message}`,"error",8e3)}}}async function E(o,l){showNotification(`\u23F3 Generating SSL certificate for ${l}...`,"warning",6e4);let v=0;const r=12,p=async()=>{v++;try{const m=await fetch(o,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${l} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return v<r?setTimeout(p,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(p,3e3)}function R(){safeGetJSON(w,[]).forEach(l=>{window.APPS.some(v=>v.id===l.id)||window.APPS.push(l)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{g(),I.classList.add("show")}),wireModal(I,document.getElementById("app-selector-cancel"));const O=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{O.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const o=JSON.parse(O.dataset.appTemplate),l=document.getElementById("deploy-media-path").value.trim(),v=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{v.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const r={appTemplate:o,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:l||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:v.length>0?v:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!r.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(o.mediaMount?.required&&!l){showNotification("Please enter a media library path for this application","warning");return}O.classList.remove("show"),x(r)}),wireModal(O);const D=document.getElementById("folder-browser-modal"),N=document.getElementById("folder-browser-path"),M=document.getElementById("folder-browser-list"),H=document.getElementById("folder-browser-selected"),S=document.getElementById("folder-browser-selected-list");let T="",b=[],u=null;window.openFolderBrowser=function(o){u=o,b=o.value.split(",").map(l=>l.trim()).filter(l=>l),T="",c(),y(""),D.classList.add("show")};async function y(o){N.textContent=o||"Select a drive...",M.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const v=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(o)}`)).json();if(!v.success){M.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(v.error)}</div>`;return}T=v.path||"",N.textContent=T||"Select a drive...";let r="";v.parent&&v.parent!==v.path&&(r+=`<div class="folder-item" data-path="${escapeHtml(v.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>`),v.items.length===0&&!v.parent?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':v.items.length===0?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':v.items.forEach(p=>{const m=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",n=b.includes(p.path),e=n?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";r+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${e}">
|
|
<span style="font-size: 1.2rem;">${m}</span>
|
|
<span style="flex: 1;">${escapeHtml(p.name)}</span>
|
|
${n?'<span style="color: var(--success);">\u2713</span>':""}
|
|
</div>`}),M.innerHTML=r,M.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{y(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const m=b.includes(p.dataset.path);p.style.background=m?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(l){M.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(l.message)}</div>`}}function c(){if(b.length===0){H.style.display="none";return}H.style.display="block",S.innerHTML=b.map(o=>`
|
|
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
|
|
${escapeHtml(o)}
|
|
<button type="button" onclick="removeSelectedFolder('${escapeHtml(o)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
|
</span>
|
|
`).join("")}window.removeSelectedFolder=function(o){b=b.filter(l=>l!==o),c(),y(T)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{T&&!b.includes(T)&&(b.push(T),c(),y(T))}),wireModal(D,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{u&&(u.value=b.join(", ")),D.classList.remove("show")}),R()})(),(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 w=null,B=null,z=null,I=1,C=!1;const P=document.getElementById("recipe-deploy-modal"),$=document.getElementById("recipe-cancel"),L=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(P,$);async function k(){try{const b=await fetch("/api/v1/recipes/templates"),u=await b.json();if(u.success)return w=u.templates,B=u.categories,!0;if(b.status===403)return C=!1,!1}catch(b){console.warn("Failed to fetch recipe templates:",b.message)}return!1}async function f(){try{C=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{C=!1}return C}window.renderRecipeCards=async function(b){await f();let u;if(C&&w?u=w:u=x(),!u||u.length===0)return;const y=document.createElement("div");y.className="app-category-header",y.innerHTML="\u{1F9EA} Recipes",y.style.borderBottomColor="#8e44ad",b.appendChild(y);const c=Array.isArray(u)?u:Object.values(u);c.sort((o,l)=>(l.popularity||0)-(o.popularity||0));for(const o of c){const l=document.createElement("div");l.className="app-option",l.style.position="relative";const v=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${o.componentCount||o.components?.length||"?"} apps</div>`,r=C?"":'<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>';l.innerHTML=`
|
|
${r}
|
|
<div class="app-option-icon">${escapeHtml(o.icon||"\u{1F9EA}")}</div>
|
|
<div class="app-option-name">${escapeHtml(o.name)}</div>
|
|
<div class="app-option-desc">${escapeHtml(o.description||"")}</div>
|
|
${v}
|
|
`,l.onclick=()=>{if(!C){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}E(o)},b.appendChild(l)}};function x(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function E(b){z=b,I=1;const u=document.getElementById("app-selector-modal");u&&u.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${b.name}`,R(),O(),P.classList.add("show")}function R(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(b=>{const u=parseInt(b.dataset.step);b.classList.toggle("active",u===I),b.classList.toggle("completed",u<I)});for(let b=1;b<=4;b++){const u=document.getElementById(`recipe-step-${b}`);u&&(u.style.display=b===I?"":"none")}L.style.display=I>1&&I<4?"":"none",I===4?(g.style.display="none",$.textContent="Close"):I===3?(g.textContent="\u{1F680} Deploy",g.style.display="",$.textContent="Cancel"):(g.textContent="Next",g.style.display="",$.textContent="Cancel")}function O(){const b=document.getElementById("recipe-component-list");b.innerHTML="";const u=z.components||[];for(const y of u){const c=document.createElement("div");c.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const o=y.required,l=y.internal;c.innerHTML=`
|
|
<input type="checkbox" ${o?"checked disabled":"checked"} data-component-id="${y.id}"
|
|
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(y.role||y.id)}</div>
|
|
<div style="font-size: 0.78rem; color: var(--muted);">
|
|
${y.templateRef?escapeHtml(y.templateRef):"Built-in"}
|
|
${o?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
${l?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
|
</div>
|
|
${y.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(y.note)}</div>`:""}
|
|
</div>
|
|
`,b.appendChild(c)}}function D(){const b=document.getElementById("recipe-volumes-section"),u=document.getElementById("recipe-volume-list"),y=z.sharedVolumes;if(y&&Object.keys(y).length>0){b.style.display="",u.innerHTML="";for(const[c,o]of Object.entries(y)){const l=document.createElement("div");l.style.cssText="display: grid; gap: 4px;",l.innerHTML=`
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(o.label||c)}</label>
|
|
<input type="text" data-volume-key="${c}" value="${escapeHtml(o.defaultPath||"")}"
|
|
placeholder="${escapeHtml(o.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(o.description||"")}</div>
|
|
`,u.appendChild(l)}}else b.style.display="none"}function N(){const b=document.getElementById("recipe-review-content"),u=M(),y=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),c={};y.forEach(r=>{c[r.dataset.volumeKey]=r.value});const o=document.getElementById("recipe-timezone").value||"UTC",l=document.getElementById("recipe-ip").value||"host.docker.internal",v=document.getElementById("recipe-tailscale").checked;b.innerHTML=`
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(z.name)}</div>
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(z.description||"")}</div>
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
<strong>Components (${u.length}):</strong>
|
|
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
|
${u.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(c).length>0?`<div style="margin-bottom: 12px;">
|
|
<strong>Volumes:</strong>
|
|
${Object.entries(c).map(([r,p])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${r}: ${escapeHtml(p)}</div>`).join("")}
|
|
</div>`:""}
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted);">
|
|
Timezone: ${escapeHtml(o)} • IP: ${escapeHtml(l)} ${v?"• Tailscale only":""}
|
|
</div>
|
|
|
|
${z.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(z.network.name)}</code></div>`:""}
|
|
`}function M(){const b=document.querySelectorAll("#recipe-component-list input[data-component-id]"),u=new Set;b.forEach(c=>{c.checked&&u.add(c.dataset.componentId)});const y=z.components||[];return y.filter(c=>c.required).forEach(c=>u.add(c.id)),y.filter(c=>u.has(c.id))}async function H(){const b=document.getElementById("recipe-progress-list"),u=document.getElementById("recipe-deploy-result");u.style.display="none",b.innerHTML="";const y=M();for(const v of y){const r=document.createElement("div");r.id=`recipe-progress-${v.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(v.role||v.id)}</span>
|
|
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
|
`,b.appendChild(r)}const c=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),o={};c.forEach(v=>{o[v.dataset.volumeKey]=v.value});const l={selectedComponents:y.map(v=>v.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:o},componentOverrides:{}};for(const v of y)S(v.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:z.id,config:l})})).json();if(r.success){for(const p of r.deployed||[])S(p.id,"success",p.url?`Running \u2192 ${p.url}`:"Running");for(const p of r.errors||[])S(p.componentId,"error",p.error);u.style.display="",u.innerHTML=`
|
|
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
|
|
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(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(p=>`<li>${escapeHtml(p)}</li>`).join("")}</ul>
|
|
</div>`:""}
|
|
</div>
|
|
`,showNotification(`${z.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
<strong>Deployment failed:</strong> ${escapeHtml(r.error||"Unknown error")}
|
|
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(v){u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
<strong>Network error:</strong> ${escapeHtml(v.message)}
|
|
</div>`}}function S(b,u,y){const c=document.getElementById(`recipe-progress-${b}`);if(!c)return;const o=c.querySelector(".recipe-progress-icon"),l=c.querySelector(".recipe-progress-status");u==="deploying"?(o.textContent="\u23F3",l.style.color="var(--accent)"):u==="success"?(o.textContent="\u2705",l.style.color="var(--ok-fg)"):u==="error"&&(o.textContent="\u274C",l.style.color="var(--bad-fg)"),l.textContent=y}g.addEventListener("click",()=>{if(I===3){I=4,R(),H();return}I<3&&(I++,R(),I===2&&D(),I===3&&N())}),L.addEventListener("click",()=>{I>1&&I<4&&(I--,R())}),window.groupRecipeCards=function(){const b=document.querySelectorAll(".service-card[data-recipe-id]");if(b.length===0)return;const u={};b.forEach(y=>{const c=y.dataset.recipeId;u[c]||(u[c]=[]),u[c].push(y)});for(const[y,c]of Object.entries(u))c.length<2||c.forEach((o,l)=>{if(o.style.borderLeft="3px solid rgba(142,68,173,0.5)",l===0){let v=o.querySelector(".recipe-group-label");v||(v=document.createElement("div"),v.className="recipe-group-label",v.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;",v.textContent=y.replace(/-/g," "),o.style.position="relative",o.appendChild(v))}})},window.manageRecipe=async function(b,u){const y=`/api/v1/recipes/${b}/${u}`,c=u==="remove"?"DELETE":"POST",o=u==="remove"?`/api/v1/recipes/${b}`:y;if(!(u==="remove"&&!confirm(`Remove the entire ${b} recipe? This will delete all containers and configuration.`)))try{const v=await(await secureFetch(o,{method:c})).json();v.success?(showNotification(`Recipe ${u}: ${v.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${u} failed: ${v.error}`,"error",5e3)}catch(l){showNotification(`Network error: ${l.message}`,"error",5e3)}};const T=document.createElement("style");T.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(T),f()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const w=document.getElementById("reload-caddy-top"),B=w.textContent;try{w.textContent="\u23F3 Reloading...",w.disabled=!0;const z=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await z.json();if(z.ok&&I.success)w.textContent="\u2705 Reloaded!",setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(z){w.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${z.message}`,"error"),setTimeout(()=>{w.textContent=B,w.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 w=document.getElementById("error-log-modal"),B=document.getElementById("error-log-content"),z=document.getElementById("view-error-logs"),I=document.getElementById("error-log-refresh"),C=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function $(){B.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const k=await(await fetch("/api/v1/error-logs")).json();k.success&&k.logs?k.logs.length===0?B.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':B.innerHTML=k.logs.map(f=>`
|
|
<div class="log-entry error">
|
|
<span class="log-timestamp">${new Date(f.timestamp).toLocaleString()}</span>
|
|
<span class="log-level">ERROR</span>
|
|
<div class="log-message">
|
|
<strong>${escapeHtml(f.context)}</strong>: ${escapeHtml(f.error)}
|
|
${f.details?`<br><small style="opacity: 0.7;">${escapeHtml(f.details)}</small>`:""}
|
|
</div>
|
|
</div>
|
|
`).join(""):B.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){B.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function L(){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),$()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}z?.addEventListener("click",()=>{w.classList.add("show"),$()}),I?.addEventListener("click",$),C?.addEventListener("click",L),wireModal(w,P)})(),(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 w=document.getElementById("arr-setup-modal"),B=document.getElementById("arr-setup-btn"),z=document.getElementById("arr-setup-cancel"),I=document.getElementById("smart-connect-btn"),C=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),$=document.getElementById("smart-phase-progress"),L=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),k=document.getElementById("smart-credential-inputs"),f=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),E=document.getElementById("smart-plex-libraries"),R=document.getElementById("smart-retry-btn");let O=null;const D={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},N={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(c){C.style.display=c==="detect"?"block":"none",P.style.display=c==="credentials"?"block":"none",$.style.display=c==="progress"?"block":"none",L.style.display=c==="results"?"block":"none"}function H(c){const o={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"}},l=o[c]||o.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, ${l.bg} 20%, transparent); color: ${l.bg};">${l.icon} ${l.text}</span>`}async function S(){M("detect"),g.style.display="none";try{if(O=await(await fetch("/api/v1/arr/smart-detect")).json(),!O.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(O.error)}</div>`,g.style.display="block";return}let o='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[v,r]of Object.entries(O.services)){const p=D[v]||"\u{1F4E6}",m=N[v]||v,n=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>`:"",a=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";o+=`<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;">${p}</span>
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${m}</div>
|
|
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
|
${n} ${e} ${a}
|
|
</div>
|
|
</div>
|
|
${H(r.status)}
|
|
</div>`}o+="</div>";const l=O.summary;o+=`<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(l.fullyConnected))}/${escapeHtml(String(l.totalDetected+(5-l.totalDetected)))} services detected ·
|
|
${escapeHtml(String(l.fullyConnected))} connected${l.needsApiKey>0?` · <strong>${escapeHtml(String(l.needsApiKey))} needs API key</strong>`:""}
|
|
</div>`,g.innerHTML=o,g.style.display="block",T(O),setTimeout(()=>{M("credentials")},800)}catch(c){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(c.message)}</div>`,g.style.display="block"}}function T(c){let o="";const l=c.services,v=["radarr","sonarr","prowlarr"];for(const m of v){const n=l[m];if(!n||n.status==="not_found"&&!n.url)continue;const e=D[m],a=N[m],t=n.status==="connected";o+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${t?"var(--ok-fg)":"var(--border)"};">
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
|
<span style="font-size: 1.1rem;">${e}</span>
|
|
<span style="font-weight: 500;">${a}</span>
|
|
<span id="smart-${m}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
${t?'<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-${m}-url" value="${escapeHtml(n.url||"")}" placeholder="https://seedbox.com/${m}/"
|
|
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-${m}-key" placeholder="${t?"(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('${m}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
</div>`}const r=l.plex;if(r){const m=r.status==="connected";o+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${m?"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>
|
|
${H(r.status)}
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
|
|
</div>
|
|
</div>`}const p=l.seerr;if(p){const m=p.status==="connected";let n="";if(p.configuredServices){const e=p.configuredServices;n=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} ·
|
|
${e.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
|
${e.plex?"✓ Plex":"✗ Plex"}
|
|
</div>`}o+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${m?"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>
|
|
${H(p.status)}
|
|
</div>
|
|
${n}
|
|
</div>`}k.innerHTML=o}window.smartTestConnection=async function(c){const o=document.getElementById(`smart-${c}-url`),l=document.getElementById(`smart-${c}-key`),v=document.getElementById(`smart-${c}-status`),r=o?.value.trim(),p=l?.value.trim();if(!r||!p){v.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}v.innerHTML='<span class="brand-spinner"></span>';try{const n=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:c,url:r,apiKey:p})})).json();n.success?v.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(n.appName||"Connected")} v${escapeHtml(n.version||"")}</span>`:v.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(n.error)}</span>`}catch(m){v.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(m.message)}</span>`}};async function b(){M("progress"),f.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 c={};for(const l of["radarr","sonarr","prowlarr"]){const v=document.getElementById(`smart-${l}-url`)?.value.trim(),r=document.getElementById(`smart-${l}-key`)?.value.trim();r&&v?c[l]={apiKey:r,url:v}:r&&(c[l]={apiKey:r})}const o={services:Object.keys(c).length>0?c: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 v=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();let r="";for(const p of v.steps||[]){const m=p.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',n=p.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;">
|
|
${m}
|
|
<span>${escapeHtml(p.step)}</span>
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${n};">${escapeHtml(p.details||"")}</span>
|
|
</div>`}f.innerHTML=r,setTimeout(()=>u(v),500)}catch(l){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(l.message)}</div>`}}function u(c){M("results");const o=c.summary||{},l=o.failed===0&&o.succeeded>0,v=l?"var(--ok-fg)":"#f39c12",r=l?"✓":"⚠",p=l?"All Connected!":`${escapeHtml(String(o.succeeded))}/${escapeHtml(String(o.totalSteps))} Steps Succeeded`;let m=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${v} 12%, transparent); border-radius: 10px; border: 1px solid ${v}; margin-bottom: 12px;">
|
|
<div style="font-size: 1.5rem; color: ${v};">${r}</div>
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${v};">${p}</div>
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(o.succeeded))} succeeded, ${escapeHtml(String(o.failed))} failed</div>
|
|
</div>`;m+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const n of c.steps||[]){const e=n.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';m+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
${e} ${escapeHtml(n.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(n.details||"")}</span>
|
|
</div>`}m+="</div>",x.innerHTML=m,R.style.display=o.failed>0?"block":"none",c.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&y()}async function y(){try{const o=await(await fetch("/api/v1/plex/libraries")).json();if(o.success&&o.libraries?.length>0){let l=`<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(o.serverName)} Libraries</h4>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const v of o.libraries){const r=v.type==="movie"?"\u{1F3AC}":v.type==="show"?"\u{1F4FA}":"\u{1F3B5}";l+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
${r} <strong>${escapeHtml(v.title)}</strong>
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(v.count))} items</span>
|
|
</div>`}l+="</div></div>",E.innerHTML=l,E.style.display="block"}}catch{}}B?.addEventListener("click",()=>{w.classList.add("show"),E.style.display="none",S()}),wireModal(w,z),I?.addEventListener("click",b),R?.addEventListener("click",b)})(),(function(){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>
|
|
</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>
|
|
|
|
<!-- Buttons -->
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="notifications-cancel">Cancel</button>
|
|
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const w=document.getElementById("notifications-modal"),B=document.getElementById("manage-notifications"),z=document.getElementById("notifications-save"),I=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(f=>{const x=document.getElementById(`${f}-enabled`),E=document.getElementById(`${f}-config`);x?.addEventListener("change",()=>{E.style.display=x.checked?"block":"none"})});const C=document.getElementById("health-check-enabled"),P=document.getElementById("health-check-config");C?.addEventListener("change",()=>{P.style.opacity=C.checked?"1":"0.5"});async function $(){try{const x=await(await fetch("/api/v1/notifications/config")).json();if(x.success){const E=x.config;document.getElementById("notifications-enabled").checked=E.enabled,document.getElementById("discord-enabled").checked=E.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=E.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=E.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=E.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=E.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=E.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=E.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=E.providers?.email?.enabled?"block":"none",E.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=E.providers.ntfy.serverUrl),E.providers?.email?.host&&(document.getElementById("email-host").value=E.providers.email.host),E.providers?.email?.from&&(document.getElementById("email-from").value=E.providers.email.from),document.getElementById("health-check-enabled").checked=E.healthCheck?.enabled||!1,E.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=E.healthCheck.intervalMinutes),E.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(E.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=E.events?.containerDown!==!1,document.getElementById("event-container-up").checked=E.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=E.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=E.events?.deploymentFailed!==!1}}catch(f){console.error("Failed to load notification config:",f)}}async function L(){try{const x=await(await fetch("/api/v1/notifications/history?limit=10")).json(),E=document.getElementById("notification-history");x.success&&x.history?.length>0?E.innerHTML=x.history.map(R=>{const O=new Date(R.timestamp).toLocaleString();return`
|
|
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
|
|
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[R.type]||"var(--muted)"}">${R.type==="success"?"\u2713":R.type==="error"?"\u2717":"\u2139"}</span>
|
|
<div style="flex: 1; min-width: 0;">
|
|
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(R.title)}</div>
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${O}</div>
|
|
</div>
|
|
</div>
|
|
`}).join(""):E.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(f){console.error("Failed to load notification history:",f)}}async function g(){try{const f={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},E=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)})).json();E.success?(showNotification("Notification settings saved","success",3e3),w.classList.remove("show")):showNotification(`Failed to save: ${E.error}`,"error",3e3)}catch(f){showNotification(`Error: ${f.message}`,"error",3e3)}}async function k(f){try{const E=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:f})})).json();E.success?showNotification(`Test ${f} notification sent!`,"success",3e3):showNotification(`Test failed: ${E.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const x=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();x.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.lastCheck).toLocaleString()} (${x.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(f){showNotification(`Error: ${f.message}`,"error",3e3)}}),B?.addEventListener("click",()=>{w.classList.add("show"),$(),L()}),z?.addEventListener("click",g),wireModal(w,I)})(),(function(){document.addEventListener("click",w=>{const B=w.target.closest(".panel-tab");if(!B)return;const z=B.dataset.panel;if(!z)return;const I=B.closest(".panel-tabs"),C=I.closest(".weather-modal-content");I.querySelectorAll(".panel-tab").forEach($=>$.classList.remove("active")),B.classList.add("active"),C.querySelectorAll(".panel-section").forEach($=>$.classList.remove("active"));const P=C.querySelector("#"+z);P&&P.classList.add("active")})})(),(function(){var w=["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 B(){for(var e={},a=0;a<w.length;a++){var t=w[a],s=safeGet(t);s!=null&&(e[t]=s)}try{for(var i=0;i<localStorage.length;i++){var h=localStorage.key(i);/^widget-.+-enabled$/.test(h)&&(e[h]=localStorage.getItem(h))}}catch{}return e}function z(e){if(!e||typeof e!="object")return 0;var a=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),a++);return a}function I(e){return e.version&&!e.files&&e.services}function C(e){var a={};e.customServices&&(a["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(a["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(a["weather-zip"]=e.weatherZip),e.theme&&(a.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(a["user-themes"]=JSON.stringify(e.userThemes)),z(a),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(t){var s=e.userThemes[t],i={};(window.THEME_PROPS||[]).forEach(function(h){s[h]&&(i[h]=s[h])}),secureFetch("/api/v1/themes/"+t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:s.name||t,colors:i})}).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-automated">Automated</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: Automated Backups -->
|
|
<div id="backup-automated" class="panel-section">
|
|
<div id="backup-schedule-container">
|
|
<div class="panel-empty">
|
|
<span class="empty-icon">\u23F0</span>
|
|
<span class="brand-spinner"></span> Loading backup schedule...
|
|
</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 P=document.getElementById("backup-modal"),$=document.getElementById("backup-restore-btn"),L=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),k=document.getElementById("backup-select-file"),f=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),E=document.getElementById("backup-preview"),R=document.getElementById("backup-preview-content"),O=document.getElementById("backup-do-restore-btn"),D=document.getElementById("backup-result"),N=document.getElementById("backup-schedule-container"),M=document.getElementById("backup-history-container"),H=null;$?.addEventListener("click",function(){P.classList.add("show"),D&&(D.style.display="none"),E&&(E.style.display="none"),x&&(x.style.display="none"),H=null}),wireModal(P,L),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"),a=await e.json();a.browserState=B();var t=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),s=URL.createObjectURL(t),i=document.createElement("a");i.href=s,i.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(s);var h=Object.keys(a.browserState).length,d=a.themes?Object.keys(a.themes).length:0;D.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+h+" browser settings"+(d?" + "+d+" themes":""),D.style.display="block",D.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",D.style.border="1px solid var(--ok-fg)"}catch(A){D.innerHTML="\u274C Export failed: "+escapeHtml(A.message),D.style.display="block",D.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",D.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),k?.addEventListener("click",function(){f.click()}),f?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){x.textContent="\u{1F4C4} "+a.name,x.style.display="block",D.style.display="none";try{var t=await a.text(),s=JSON.parse(t);if(I(s)){H=s;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(s.version)+")</div>";i+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',s.services?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+s.services.length+" services</span>"),s.customApps?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+s.customApps.length+" custom apps</span>"),s.theme&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(s.theme)+"</span>"),s.userThemes&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(s.userThemes).length+" custom themes</span>"),i+="</div>",R.innerHTML=i,E.style.display="block";return}var h=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)}),d=await h.json();if(d.success){H=s;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(s.exportedAt).toLocaleString()+" (v"+escapeHtml(s.version)+")</div>";i+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',i+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var A in d.preview.files){var F=d.preview.files[A],j=F.action==="create"?"\u{1F195}":"\u{1F4DD}";i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+j+" "+escapeHtml(F.description)+"</span>"}i+="</div>",d.preview.serviceCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.preview.browserStateCount&&(i+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',i+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),R.innerHTML=i,E.style.display="block"}else D.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),D.style.display="block",D.style.background="color-mix(in srgb, #f39c12 15%, transparent)",D.style.border="1px solid #f39c12",E.style.display="none"}catch(_){D.innerHTML="\u274C Could not read file: "+escapeHtml(_.message),D.style.display="block",D.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",D.style.border="1px solid var(--bad-fg)",E.style.display="none"}}}),O?.addEventListener("click",async function(){if(H&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){O.disabled=!0,O.innerHTML='<span class="brand-spinner"></span> Restoring...';try{if(I(H)){C(H),D.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",D.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",D.style.border="1px solid var(--ok-fg)",D.style.display="block",setTimeout(function(){location.reload()},2e3),O.disabled=!1,O.innerHTML="\u26A1 Restore Everything";return}var e=document.getElementById("backup-reload-caddy")?.checked??!0,a=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:H,options:{reloadCaddy:e}})}),t=await a.json(),s=0;if(H.browserState&&(s=z(H.browserState)),t.success){var i="\u2705 "+t.message;s>0&&(i+='<br><small style="color: var(--muted);">'+s+" browser settings restored</small>"),t.results.caddyReloaded&&(i+='<br><small style="color: var(--muted);">Caddy configuration reloaded</small>'),D.innerHTML=i,D.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",D.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else D.innerHTML="\u26A0\uFE0F "+escapeHtml(t.message),s>0&&(D.innerHTML+='<br><small style="color: var(--muted);">'+s+" browser settings were restored</small>"),t.results?.errors?.length>0&&(D.innerHTML+="<br><small>"+t.results.errors.map(function(h){return escapeHtml(h.file)+": "+escapeHtml(h.error)}).join(", ")+"</small>"),D.style.background="color-mix(in srgb, #f39c12 15%, transparent)",D.style.border="1px solid #f39c12";D.style.display="block"}catch(h){D.innerHTML="\u274C Restore failed: "+escapeHtml(h.message),D.style.display="block",D.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",D.style.border="1px solid var(--bad-fg)"}O.disabled=!1,O.innerHTML="\u26A1 Restore Everything"}});var S={type:"local"};async function T(){if(N)try{var e=await fetch("/api/v1/backups/config"),a=await e.json();if(!a.success)throw new Error(a.error||"Failed to load config");var t=a.config?.backups||{},s=Object.keys(t)[0],i=s?t[s]:null,h=i?.destinations&&i.destinations[0]||{type:"local"};S=JSON.parse(JSON.stringify(h));var d='<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); margin-bottom: 16px;">';d+='<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">\u23F0 Backup Schedule</h4>',d+='<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">',d+='<div><label style="font-size: 0.8rem; color: var(--muted);">Schedule:</label>',d+=' <select id="backup-schedule-select" style="width: 100%;">',d+=' <option value="disabled"'+(i?.enabled?"":" selected")+">Disabled</option>",d+=' <option value="hourly"'+(i?.schedule==="hourly"?" selected":"")+">Hourly</option>",d+=' <option value="daily"'+(i?.schedule==="daily"?" selected":"")+">Daily</option>",d+=' <option value="weekly"'+(i?.schedule==="weekly"?" selected":"")+">Weekly</option>",d+=' <option value="monthly"'+(i?.schedule==="monthly"?" selected":"")+">Monthly</option>",d+=" </select></div>",d+='<div><label style="font-size: 0.8rem; color: var(--muted);">Keep last:</label>',d+=' <select id="backup-retention-select" style="width: 100%;">',d+=' <option value="3"'+(i?.retention?.keep===3?" selected":"")+">3 backups</option>",d+=' <option value="5"'+(!i?.retention||i?.retention?.keep===5?" selected":"")+">5 backups</option>",d+=' <option value="10"'+(i?.retention?.keep===10?" selected":"")+">10 backups</option>",d+=' <option value="30"'+(i?.retention?.keep===30?" selected":"")+">30 backups</option>",d+=" </select></div>",d+="</div>",d+='<div style="margin-top: 12px;">',d+=' <label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;">',d+=' <input type="checkbox" id="backup-encrypt-toggle"'+(i?.encrypt!==!1?" checked":"")+" />",d+=" Encrypt backups",d+=" </label></div>",d+='<div style="display: flex; gap: 8px; margin-top: 12px;">',d+=' <button id="backup-save-schedule" style="padding: 8px 16px; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 6px; cursor: pointer; font-weight: 500;">Save Schedule</button>',d+=' <button id="backup-run-now" style="padding: 8px 16px; border-radius: 6px; cursor: pointer;">\u25B6\uFE0F Run Backup Now</button>',d+="</div>",d+="</div>",d+='<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); margin-bottom: 16px;">',d+='<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">\u2601\uFE0F Backup Destination</h4>',d+='<div><label style="font-size: 0.8rem; color: var(--muted);">Where to store backups:</label>',d+=' <select id="backup-dest-type" style="width: 100%;">',d+=' <option value="local"'+(S.type==="local"?" selected":"")+">\u{1F4BE} Local disk</option>",d+=' <option value="dropbox"'+(S.type==="dropbox"?" selected":"")+">\u{1F4E6} Dropbox</option>",d+=' <option value="webdav"'+(S.type==="webdav"?" selected":"")+">\u{1F310} WebDAV (Nextcloud / ownCloud)</option>",d+=' <option value="sftp"'+(S.type==="sftp"?" selected":"")+">\u{1F510} SFTP</option>",d+=" </select></div>",d+='<div id="backup-dest-form" style="margin-top: 12px;"></div>',d+='<div id="backup-dest-result" style="display: none; margin-top: 10px; padding: 8px 10px; border-radius: 6px; font-size: 0.8rem;"></div>',d+="</div>",d+='<div id="backup-schedule-result" style="display: none; margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 0.85rem;"></div>',N.innerHTML=d,document.getElementById("backup-save-schedule")?.addEventListener("click",p),document.getElementById("backup-run-now")?.addEventListener("click",m);var A=document.getElementById("backup-dest-type");A?.addEventListener("change",function(){S={type:A.value},b(A.value)}),b(S.type)}catch(F){N.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: '+escapeHtml(F.message)+"</div>"}}async function b(e){var a=document.getElementById("backup-dest-form");if(a){if(e==="local"){a.innerHTML='<div style="font-size: 0.8rem; color: var(--muted); padding: 8px;">Backups are stored on the host filesystem. No additional configuration required.</div>';return}var t="";if(e==="dropbox"?(t+='<label style="font-size: 0.8rem; color: var(--muted);">Access Token:</label>',t+='<input type="password" id="dest-dropbox-token" placeholder="sl.B..." style="width: 100%; margin-bottom: 8px;" />',t+='<label style="font-size: 0.8rem; color: var(--muted);">Folder path:</label>',t+='<input type="text" id="dest-dropbox-path" placeholder="/dashcaddy-backups" value="'+escapeHtml(S.path||"/dashcaddy-backups")+'" style="width: 100%; margin-bottom: 8px;" />',t+='<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 8px;">Generate a token at <a href="https://www.dropbox.com/developers/apps" target="_blank" style="color: var(--accent);">Dropbox App Console</a> with files.content.write + files.content.read scopes.</div>'):e==="webdav"?(t+='<label style="font-size: 0.8rem; color: var(--muted);">Server URL:</label>',t+='<input type="text" id="dest-webdav-url" placeholder="https://cloud.example.com/remote.php/dav/files/username" style="width: 100%; margin-bottom: 8px;" />',t+='<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">',t+=' <div><label style="font-size: 0.8rem; color: var(--muted);">Username:</label>',t+=' <input type="text" id="dest-webdav-username" style="width: 100%;" /></div>',t+=' <div><label style="font-size: 0.8rem; color: var(--muted);">Password / App password:</label>',t+=' <input type="password" id="dest-webdav-password" style="width: 100%;" /></div>',t+="</div>",t+='<label style="font-size: 0.8rem; color: var(--muted);">Folder path:</label>',t+='<input type="text" id="dest-webdav-path" placeholder="/dashcaddy-backups" value="'+escapeHtml(S.path||"/dashcaddy-backups")+'" style="width: 100%; margin-bottom: 8px;" />'):e==="sftp"&&(t+='<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">',t+=' <div><label style="font-size: 0.8rem; color: var(--muted);">Host:</label>',t+=' <input type="text" id="dest-sftp-host" placeholder="backup.example.com" style="width: 100%;" /></div>',t+=' <div><label style="font-size: 0.8rem; color: var(--muted);">Port:</label>',t+=' <input type="number" id="dest-sftp-port" value="22" style="width: 100%;" /></div>',t+="</div>",t+='<label style="font-size: 0.8rem; color: var(--muted);">Username:</label>',t+='<input type="text" id="dest-sftp-username" style="width: 100%; margin-bottom: 8px;" />',t+='<label style="font-size: 0.8rem; color: var(--muted);">Auth method:</label>',t+='<select id="dest-sftp-authtype" style="width: 100%; margin-bottom: 8px;">',t+=' <option value="password">Password</option>',t+=' <option value="key">Private key</option>',t+="</select>",t+='<div id="dest-sftp-password-row"><label style="font-size: 0.8rem; color: var(--muted);">Password:</label>',t+=' <input type="password" id="dest-sftp-password" style="width: 100%; margin-bottom: 8px;" /></div>',t+='<div id="dest-sftp-key-row" style="display: none;"><label style="font-size: 0.8rem; color: var(--muted);">Private key (PEM):</label>',t+=' <textarea id="dest-sftp-privatekey" rows="4" style="width: 100%; font-family: monospace; font-size: 0.75rem; margin-bottom: 8px;" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea></div>',t+='<label style="font-size: 0.8rem; color: var(--muted);">Remote path:</label>',t+='<input type="text" id="dest-sftp-path" placeholder="/home/user/dashcaddy-backups" value="'+escapeHtml(S.path||"/home/user/dashcaddy-backups")+'" style="width: 100%; margin-bottom: 8px;" />'),t+='<div style="display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap;">',t+=' <button id="dest-save-creds" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; cursor: pointer;">\u{1F4BE} Save Credentials</button>',t+=' <button id="dest-test-conn" style="padding: 6px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 6px; cursor: pointer;">\u{1F50C} Test Connection</button>',t+=' <button id="dest-clear-creds" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; cursor: pointer; color: var(--bad-fg);">\u{1F5D1}\uFE0F Clear</button>',t+="</div>",a.innerHTML=t,e==="sftp"){var s=document.getElementById("dest-sftp-authtype"),i=document.getElementById("dest-sftp-password-row"),h=document.getElementById("dest-sftp-key-row");s?.addEventListener("change",function(){s.value==="key"?(i.style.display="none",h.style.display=""):(i.style.display="",h.style.display="none")})}document.getElementById("dest-save-creds")?.addEventListener("click",function(){o(e)}),document.getElementById("dest-test-conn")?.addEventListener("click",function(){r(e)}),document.getElementById("dest-clear-creds")?.addEventListener("click",function(){l(e)}),await y(e)}}function u(e,a){var t=document.getElementById("backup-dest-result");t&&(t.innerHTML=e,t.style.display="block",t.style.background=a?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",t.style.border=a?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)")}async function y(e){try{var a=await fetch("/api/v1/backups/credentials/"+e),t=await a.json();if(!t.success||!t.credentials)return;var s=t.credentials;if(e==="dropbox"){var i=document.getElementById("dest-dropbox-token");i&&s.token&&(i.value=s.token)}else if(e==="webdav"){var h=document.getElementById("dest-webdav-url");h&&s.url&&(h.value=s.url);var d=document.getElementById("dest-webdav-username");d&&s.username&&(d.value=s.username);var A=document.getElementById("dest-webdav-password");A&&s.password&&(A.value=s.password)}else if(e==="sftp"){var F=document.getElementById("dest-sftp-host");F&&s.host&&(F.value=s.host);var j=document.getElementById("dest-sftp-port");j&&s.port&&(j.value=s.port);var _=document.getElementById("dest-sftp-username");_&&s.username&&(_.value=s.username);var U=document.getElementById("dest-sftp-password");U&&s.password&&(U.value=s.password);var W=document.getElementById("dest-sftp-privatekey");if(W&&s.privateKey&&(W.value=s.privateKey),s.privateKey){var Z=document.getElementById("dest-sftp-authtype");Z&&(Z.value="key",Z.dispatchEvent(new Event("change")))}}}catch{}}function c(e){if(e==="dropbox")return{token:document.getElementById("dest-dropbox-token")?.value};if(e==="webdav")return{url:document.getElementById("dest-webdav-url")?.value,username:document.getElementById("dest-webdav-username")?.value,password:document.getElementById("dest-webdav-password")?.value};if(e==="sftp"){var a=document.getElementById("dest-sftp-authtype")?.value,t={host:document.getElementById("dest-sftp-host")?.value,port:parseInt(document.getElementById("dest-sftp-port")?.value)||22,username:document.getElementById("dest-sftp-username")?.value};return a==="key"?t.privateKey=document.getElementById("dest-sftp-privatekey")?.value:t.password=document.getElementById("dest-sftp-password")?.value,t}return{}}async function o(e){try{var a=c(e),t=await secureFetch("/api/v1/backups/credentials/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),s=await t.json();u(s.success?"\u2705 Credentials saved":"\u26A0\uFE0F "+escapeHtml(s.error||"Failed"),s.success)}catch(i){u("\u274C "+escapeHtml(i.message),!1)}}async function l(e){if(confirm("Delete saved "+e+" credentials?"))try{var a=await secureFetch("/api/v1/backups/credentials/"+e,{method:"DELETE"}),t=await a.json();t.success?(u("\u2705 Credentials cleared",!0),b(e)):u("\u26A0\uFE0F "+escapeHtml(t.error||"Failed"),!1)}catch(s){u("\u274C "+escapeHtml(s.message),!1)}}function v(e){var a={type:e};return e==="local"||(e==="dropbox"?a.path=document.getElementById("dest-dropbox-path")?.value||"/dashcaddy-backups":e==="webdav"?a.path=document.getElementById("dest-webdav-path")?.value||"/dashcaddy-backups":e==="sftp"&&(a.path=document.getElementById("dest-sftp-path")?.value||"/dashcaddy-backups")),a}async function r(e){u('<span class="brand-spinner"></span> Testing connection...',!0);try{var a=v(e),t=await secureFetch("/api/v1/backups/test-destination",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),s=await t.json();if(s.success){var i=s.elapsedMs?" ("+s.elapsedMs+"ms)":"";u("\u2705 Connection OK"+i+" \u2014 write/read/delete probe succeeded",!0)}else u("\u274C "+escapeHtml(s.error||"Connection failed"),!1)}catch(h){u("\u274C "+escapeHtml(h.message),!1)}}async function p(){var e=document.getElementById("backup-schedule-select")?.value,a=parseInt(document.getElementById("backup-retention-select")?.value)||5,t=document.getElementById("backup-encrypt-toggle")?.checked??!0,s=document.getElementById("backup-dest-type")?.value||"local",i=document.getElementById("backup-schedule-result");try{var h=await secureFetch("/api/v1/backups/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backups:{auto:{enabled:e!=="disabled",schedule:e==="disabled"?"daily":e,include:["all"],encrypt:t,verify:!0,retention:{keep:a},destinations:[v(s)]}}})}),d=await h.json();i&&(i.innerHTML=d.success?"\u2705 Schedule saved":"\u26A0\uFE0F "+escapeHtml(d.error),i.style.display="block",i.style.background=d.success?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",i.style.border=d.success?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)",setTimeout(function(){i&&(i.style.display="none")},3e3))}catch(A){i&&(i.innerHTML="\u274C "+escapeHtml(A.message),i.style.display="block",i.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",i.style.border="1px solid var(--bad-fg)")}}async function m(){var e=document.getElementById("backup-run-now"),a=document.getElementById("backup-schedule-result"),t=document.getElementById("backup-dest-type")?.value||"local";e&&(e.disabled=!0,e.innerHTML='<span class="brand-spinner"></span> Running...');try{var s=await secureFetch("/api/v1/backups/execute",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({include:["all"],destinations:[v(t)]})}),i=await s.json();if(a){if(i.success){var h=i.backup?.size?(i.backup.size/1024/1024).toFixed(2):"?";a.innerHTML="\u2705 Backup complete ("+h+" MB)",a.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",a.style.border="1px solid var(--ok-fg)"}else a.innerHTML="\u26A0\uFE0F "+escapeHtml(i.error),a.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",a.style.border="1px solid var(--bad-fg)";a.style.display="block"}n()}catch(d){a&&(a.innerHTML="\u274C "+escapeHtml(d.message),a.style.display="block",a.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",a.style.border="1px solid var(--bad-fg)")}e&&(e.disabled=!1,e.innerHTML="\u25B6\uFE0F Run Backup Now")}async function n(){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"),a=await e.json();if(!a.success||!a.history?.length){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span> No backup history yet</div>';return}for(var t='<div style="display: flex; flex-direction: column; gap: 6px;">',s=0;s<a.history.length;s++){var i=a.history[s],h=i.size?(i.size/1024/1024).toFixed(2):"?";t+='<div style="padding: 10px 12px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem;">',t+=' <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">',t+=' <span style="font-weight: 500;">'+escapeHtml(i.name||"backup")+"</span>",t+=' <div style="display: flex; align-items: center; gap: 8px;">',t+=' <span class="status-badge '+(i.status==="success"?"success":"down")+'">'+escapeHtml(i.status)+"</span>",i.status==="success"&&(t+=' <button class="backup-restore-btn" data-backup-id="'+escapeHtml(i.id)+'" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>'),t+=" </div>",t+=" </div>",t+=' <div style="font-size: 0.75rem; color: var(--muted);">',t+=" "+new Date(i.timestamp).toLocaleString()+" | "+h+" MB | "+(i.duration?(i.duration/1e3).toFixed(1)+"s":"--"),i.encrypted&&(t+=" | \u{1F512}"),t+=" </div>",t+="</div>"}t+="</div>",M.innerHTML=t,M.querySelectorAll(".backup-restore-btn").forEach(function(d){d.addEventListener("click",function(){window.__restoreServerBackup(d.dataset.backupId)})})}catch(d){M.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(d.message)+"</div>"}}}window.__restoreServerBackup=async function(e){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var a=await secureFetch("/api/v1/backups/restore/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),t=await a.json();t.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(t.error||"Unknown error"),"error")}catch(s){showNotification("Restore error: "+s.message,"error")}},document.querySelector('[data-panel="backup-automated"]')?.addEventListener("click",T),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",n)})(),(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 w=document.getElementById("stats-modal"),B=document.getElementById("container-stats-btn"),z=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),C=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),$=document.getElementById("stats-aggregated-container"),L=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let k=null,f=null;function x(r){if(r===0||!r)return"0 B";const p=1024,m=["B","KB","MB","GB"],n=Math.floor(Math.log(r)/Math.log(p));return parseFloat((r/Math.pow(p,n)).toFixed(1))+" "+m[n]}function E(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function R(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function O(){try{let r=null,p=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,p=!0,f=e.stats)}catch{}if(!p){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const a of e.stats)r[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};f=r}}if(!r||Object.keys(r).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[n,e]of Object.entries(r)){const a=e.current||e,t=a.cpu?.percent||0,s=a.memory?.percent||0,i=E(t),h=R(s),d=a.memory?.usage||a.memory?.used||0,A=a.memory?.limit||0,F=a.network?.rxBytes||a.network?.rx||0,j=a.network?.txBytes||a.network?.tx||0,_=e.aggregated;m+=`
|
|
<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||n}</span>
|
|
${_?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${_.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
|
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${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(t,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;">${t.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(s,100)}%; background: ${h}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
</div>
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${h}; min-width: 45px; text-align: right;">${s.toFixed(1)}%</span>
|
|
</div>
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(d)} / ${x(A)}</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
|
<div style="font-size: 0.8rem;">
|
|
<span style="color: #3498db;">\u2193 ${x(F)}</span>
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
<span style="color: #e74c3c;">\u2191 ${x(j)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`}m+="</div>",P.innerHTML=m,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(r.message)}</div>`}}async function D(){if(!$)return;const r=f;if(!r||Object.keys(r).length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let p='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[m,n]of Object.entries(r)){const e=n.aggregated;e&&(p+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${n.name||m}</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>`)}p+="</div>",$.innerHTML=p}async function N(){if(!L)return;L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const r=f;if(!r||Object.keys(r).length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let p='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[m,n]of Object.entries(r)){const e=n.alertConfig||{};p+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
|
<span style="font-weight: 600; flex: 1;">${n.name||m}</span>
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
<input type="checkbox" class="alert-enabled" data-container="${m}" ${e.enabled?"checked":""} /> Enabled
|
|
</label>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
|
|
<input type="number" class="alert-cpu" data-container="${m}" value="${e.cpuThreshold||80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
|
</div>
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
|
|
<input type="number" class="alert-mem" data-container="${m}" value="${e.memoryThreshold||85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
|
</div>
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
|
|
<input type="number" class="alert-cooldown" data-container="${m}" value="${e.cooldownMinutes||15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
<input type="checkbox" class="alert-autorestart" data-container="${m}" ${e.autoRestart?"checked":""} /> Auto-restart on breach
|
|
</label>
|
|
<span style="flex: 1;"></span>
|
|
<button class="alert-save-btn" data-container="${m}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
|
|
</div>
|
|
</div>`}p+="</div>",L.innerHTML=p,L.querySelectorAll(".alert-save-btn").forEach(m=>{m.addEventListener("click",async()=>{const n=m.dataset.container,e=L.querySelector(`.alert-enabled[data-container="${n}"]`)?.checked||!1,a=parseInt(L.querySelector(`.alert-cpu[data-container="${n}"]`)?.value)||80,t=parseInt(L.querySelector(`.alert-mem[data-container="${n}"]`)?.value)||85,s=parseInt(L.querySelector(`.alert-cooldown[data-container="${n}"]`)?.value)||15,i=L.querySelector(`.alert-autorestart[data-container="${n}"]`)?.checked||!1;try{const d=await(await secureFetch(`/api/v1/monitoring/alerts/${n}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:a,memoryThreshold:t,cooldownMinutes:s,autoRestart:i})})).json();m.textContent=d.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{m.textContent="Save"},2e3)}catch{m.textContent="\u274C Error",setTimeout(()=>{m.textContent="Save"},2e3)}})})}function M(){k&&clearInterval(k),C?.checked&&(k=setInterval(O,DC.POLL.STATS))}function H(){k&&(clearInterval(k),k=null)}B?.addEventListener("click",()=>{w.classList.add("show"),O(),M()}),z?.addEventListener("click",()=>{w.classList.remove("show"),H()}),w?.addEventListener("click",r=>{r.target===w&&(w.classList.remove("show"),H())}),I?.addEventListener("click",O),C?.addEventListener("change",()=>{C.checked?M():H()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",D),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",N);const S=document.getElementById("stats-history-container"),T=document.getElementById("stats-history-container-area"),b=document.querySelectorAll(".stats-range-btn");let u="1h";function y(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 c(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function o(r,p,m,n,e){if(!r||r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const a=r.map(p).filter(W=>W!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const t=Math.max(...a,1),s=Math.min(...a,0),i=t-s||1,h=600,d=80,A=4,F=(h-A*2)/Math.max(a.length-1,1),j=a.map((W,Z)=>{const G=A+Z*F,Q=d-A-(W-s)/i*(d-A*2);return`${G.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=a[a.length-1],U=a.reduce((W,Z)=>W+Z,0)/a.length;return`
|
|
<div style="margin-bottom: 14px;">
|
|
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
|
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(n)}</span>
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e}</span>
|
|
</div>
|
|
<svg viewBox="0 0 ${h} ${d}" preserveAspectRatio="none" style="width: 100%; height: ${d}px; background: var(--base); border-radius: 4px;">
|
|
<polyline fill="none" stroke="${m}" stroke-width="1.5" points="${j}" />
|
|
</svg>
|
|
</div>
|
|
`}function l(){if(!S)return;const r=f||{},p=S.value,m=Object.entries(r);if(m.length===0){S.innerHTML='<option value="">No containers</option>';return}S.innerHTML=m.map(([n,e])=>`<option value="${escapeHtml(n)}">${escapeHtml(e.name||n)}</option>`).join(""),p&&r[p]&&(S.value=p)}async function v(){if(!T||!S)return;const r=S.value;if(!r){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const p=Date.now(),m=p-y(u);T.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=${m}&endTime=${p}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],t=e.tier||"raw";if(a.length===0){T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${u}. Tier: ${c(t)}.</div>`;return}const s=t==="raw",i=s?j=>j.cpu?.percent:j=>j.cpu?.avg,h=s?j=>j.memory?.percent:j=>j.memory?.avgPercent,d=s?j=>j.network?.rxMB||0:j=>j.network?.rxMB||0,A=s?j=>j.network?.txMB||0:j=>j.network?.txMB||0;let F=`
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
|
${a.length} samples \xB7 ${escapeHtml(c(t))} \xB7 ${new Date(m).toLocaleString()} \u2192 ${new Date(p).toLocaleString()}
|
|
</div>
|
|
`;F+=o(a,i,"#2ecc71","CPU","%"),F+=o(a,h,"#3498db","Memory","%"),F+=o(a,d,"#9b59b6","Network RX"," MB"),F+=o(a,A,"#e67e22","Network TX"," MB"),T.innerHTML=F}catch(n){T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(n.message)}</div>`}}b.forEach(r=>{r.addEventListener("click",()=>{b.forEach(p=>p.classList.remove("active")),r.classList.add("active"),u=r.dataset.range,v()})}),S?.addEventListener("change",v),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{l(),v()})})(),(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 w=document.getElementById("health-modal"),B=document.getElementById("health-check-btn"),z=document.getElementById("health-cancel"),I=document.getElementById("health-refresh-btn"),C=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),$=document.getElementById("health-config-container"),L=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),k=document.getElementById("health-config-form"),f=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),E=document.getElementById("health-form-save");let R=null;function O(b){return b>=99.9?"var(--ok-fg)":b>=95?"#f39c12":"var(--bad-fg)"}function D(b){const u={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${u[b]||"var(--muted)"}20; color: ${u[b]||"var(--muted)"};">${b}</span>`}async function N(){try{const u=await(await fetch("/api/v1/health-checks/status")).json();if(!u.success||!u.status||Object.keys(u.status).length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const y=Object.values(u.status);let c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',c+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',c+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',c+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const o of y){const l=o.status==="up",v=l?"var(--dot-ok)":"var(--dot-bad)",r=o.uptime?.["24h"]??"-",p=o.uptime?.["7d"]??"-",m=o.avgResponseTime!=null?Math.round(o.avgResponseTime)+"ms":"-",n=o.timestamp?timeAgo(o.timestamp):"-";c+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(o.serviceId)}">`,c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(o.name||o.serviceId)}</td>`,c+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${v}; margin-right: 6px;"></span>${l?"Up":"Down"}</td>`,c+=`<td style="padding: 8px; color: ${typeof r=="number"?O(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,c+=`<td style="padding: 8px; color: ${typeof p=="number"?O(p):"var(--muted)"};">${typeof p=="number"?p.toFixed(1)+"%":p}</td>`,c+=`<td style="padding: 8px;">${m}</td>`,c+=`<td style="padding: 8px; color: var(--muted);">${n}</td>`,c+="</tr>",c+=`<tr id="health-detail-${escapeHtml(o.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>`}c+="</table>",C.innerHTML=c,L.textContent="Updated "+new Date().toLocaleTimeString(),C.querySelectorAll("tr[data-health-id]").forEach(o=>{o.addEventListener("click",async()=>{const l=o.dataset.healthId,v=document.getElementById("health-detail-"+l);if(v){if(v.style.display!=="none"){v.style.display="none";return}v.style.display="";try{const p=await(await fetch(`/api/v1/health-checks/${l}/stats?hours=24`)).json();if(p.success&&p.stats){const m=p.stats,n=m.responseTime||{};v.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>${m.totalChecks||0}</strong></div>
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${O(m.uptime||0)};">${(m.uptime||0).toFixed(2)}%</strong></div>
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(n.avg||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(n.p95||0)}ms / ${Math.round(n.p99||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(n.min||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(n.max||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${m.upChecks||0}</strong></div>
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${m.downChecks||0}</strong></div>
|
|
</div>`}else v.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){v.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(b){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(b.message)}</div>`}}async function M(){try{const[b,u]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),y=await b.json(),c=await u.json();let o="";const l=y.success&&y.incidents?y.incidents:[];if(l.length>0){o+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+l.length+")</h4>";for(const r of l)o+=`<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>${D(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>`;o+="</div>"}else o+='<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 v=c.success&&c.history?c.history:[];if(v.length>0){o+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',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;">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 v){const p=r.status==="resolved",m=p&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";o+='<tr style="border-bottom: 1px solid var(--border);">',o+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,o+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,o+=`<td style="padding: 6px;">${D(r.severity)}</td>`,o+=`<td style="padding: 6px;"><span style="color: ${p?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,o+=`<td style="padding: 6px;">${m}</td>`,o+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,o+="</tr>"}o+="</table>"}P.innerHTML=o||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(b){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(b.message)}</div>`}}async function H(){try{const u=await(await fetch("/api/v1/health-checks/status")).json(),y=u.success&&u.status?Object.values(u.status):[];if(y.length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<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 o of y){const l=o.status==="up";c+='<tr style="border-bottom: 1px solid var(--border);">',c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(o.name||o.serviceId)}</td>`,c+=`<td style="padding: 8px; color: ${l?"var(--ok-fg)":"var(--bad-fg)"};">${l?"Up":"Down"}</td>`,c+=`<td style="padding: 8px;">${o.sla?.target?o.sla.target+"%":"-"}</td>`,c+='<td style="padding: 8px; text-align: right;">',c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(o.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(o.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,c+="</td></tr>"}c+="</table>",$.innerHTML=c}catch(b){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(b.message)}</div>`}}function S(b,u,y,c,o,l,v){R=b||null,f.textContent=b?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=b||"",document.getElementById("health-form-id").disabled=!!b,document.getElementById("health-form-name").value=u||"",document.getElementById("health-form-url").value=y||"",document.getElementById("health-form-timeout").value=c||1e4,document.getElementById("health-form-codes").value=o||"200",document.getElementById("health-form-sla").value=l||99.9,document.getElementById("health-form-slow").value=v||5e3,k.style.display="",g.style.display="none"}function T(){k.style.display="none",g.style.display="",R=null}g?.addEventListener("click",()=>S("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",T),E?.addEventListener("click",async()=>{const b=R||document.getElementById("health-form-id").value.trim();if(!b)return showNotification("Service ID is required","warning");const u=document.getElementById("health-form-url").value.trim();if(!u)return showNotification("URL is required","warning");const y=document.getElementById("health-form-codes").value.split(",").map(o=>parseInt(o.trim())).filter(Boolean),c={name:document.getElementById("health-form-name").value.trim()||b,url:u,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:y.length?y:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)||5e3};try{E.textContent="Saving...",E.disabled=!0;const l=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(b)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)})).json();if(!l.success)throw new Error(l.error||"Save failed");T(),H(),N()}catch(o){showNotification("Error: "+o.message,"error")}finally{E.textContent="Save",E.disabled=!1}}),document.addEventListener("health-edit",async b=>{const u=b.detail;S(u,"","",1e4,"200",99.9,5e3)}),document.addEventListener("health-delete",async b=>{const u=b.detail;if(confirm(`Delete health check for "${u}"?`))try{const c=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(u)}/configure`,{method:"DELETE"})).json();if(!c.success)throw new Error(c.error);H(),N()}catch(y){showNotification("Error: "+y.message,"error")}}),B?.addEventListener("click",()=>{w?.classList.add("show"),N()}),wireModal(w,z),I?.addEventListener("click",N),document.querySelector('[data-panel="health-incidents"]')?.addEventListener("click",M),document.querySelector('[data-panel="health-config"]')?.addEventListener("click",H)})(),(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;">
|
|
<button id="updates-check-btn" class="btn-accent-solid">\u{1F50D} Check for Updates</button>
|
|
</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 w=document.getElementById("updates-modal"),B=document.getElementById("updates-btn"),z=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),C=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),$=document.getElementById("updates-auto-container"),L=document.getElementById("updates-last-check");async function g(){try{const m=await(await fetch("/api/v1/updates/available")).json();if(!m.success)throw new Error(m.error);const n=m.updates||[];if(n.length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',L.textContent="";return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const a of n)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(a.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(a.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.currentDigest)}</code></td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",C.innerHTML=e,L.textContent=n.length+" update(s) available",C.querySelectorAll(".update-now-btn").forEach(a=>{a.addEventListener("click",async()=>{const t=a.dataset.id,s=a.dataset.name;if(confirm(`Update "${s}" to the latest version? The container will restart.`)){a.textContent="Updating...",a.disabled=!0;try{const h=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(h.success)a.textContent="Done!",a.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(h.error||"Update failed")}catch(i){a.textContent="Failed",a.style.color="var(--bad-fg)",showNotification("Update error: "+i.message,"error"),setTimeout(()=>{a.textContent="Update",a.disabled=!1,a.style.color="",a.style.background=""},3e3)}}})}),C.querySelectorAll(".rollback-btn").forEach(a=>{a.addEventListener("click",async()=>{const t=a.dataset.id,s=a.dataset.name;if(confirm(`Rollback "${s}" to its previous version?`)){a.textContent="Rolling back...",a.disabled=!0;try{const h=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(h.success)a.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(h.error||"Rollback failed")}catch(i){a.textContent="Failed",showNotification("Rollback error: "+i.message,"error"),setTimeout(()=>{a.textContent="Rollback",a.disabled=!1},3e3)}}})})}catch(p){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function k(){I.textContent="\u{1F50D} Checking...",I.disabled=!0;try{const m=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!m.success)throw new Error(m.error);I.textContent="\u2705 Done!",await g()}catch(p){I.textContent="\u274C Failed",showNotification("Check error: "+p.message,"error")}setTimeout(()=>{I.textContent="\u{1F50D} Check for Updates",I.disabled=!1},3e3)}async function f(){try{P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const m=await(await fetch("/api/v1/updates/history?limit=50")).json(),n=m.success&&m.history?m.history:[];if(n.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">Status</th></tr>';for(const a of n){const t=a.status==="success",s=a.duration?a.duration<1e3?a.duration+"ms":Math.round(a.duration/1e3)+"s":"-";e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(a.timestamp)}</td>`,e+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(a.containerName)}</td>`,e+=`<td style="padding: 6px; color: var(--muted);">${escapeHtml(a.imageName)}</td>`,e+=`<td style="padding: 6px;">${s}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713 success":"\u2717 failed"}</span></td>`,e+="</tr>",!t&&a.error&&(e+=`<tr><td colspan="5" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">${escapeHtml(a.error)}</td></tr>`)}e+="</table>",P.innerHTML=e}catch(p){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function x(){try{$.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const[p,m]=await Promise.all([fetch("/api/v1/stats/containers"),fetch("/api/v1/updates/auto-update")]),n=await p.json(),e=await m.json(),a=n.success&&n.stats?n.stats:[],t=e.success&&e.config?e.config:{};if(a.length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F916}</span>No running containers found.</div>';return}let s='<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>';s+='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">',s+='<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 h=i.name||i.Names?.[0]?.replace(/^\//,"")||i.Id?.substring(0,12),d=i.containerId||i.Id,A=t[d]||{},F=A.enabled?A.schedule||"weekly":"",j=A.autoRollback!==!1,_=A.maintenanceWindow||"",U=A.lastAutoUpdate?timeAgo(A.lastAutoUpdate):"Never";s+=`<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(d)}">`,s+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(h)}</td>`,s+=`<td style="padding: 8px;">
|
|
<select class="auto-schedule" data-id="${escapeHtml(d)}" 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>`,s+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(d)}" value="${escapeHtml(_)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,s+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(d)}"${j?" checked":""} /></td>`,s+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,s+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(d)}" data-name="${escapeHtml(h)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,s+="</tr>"}s+="</table>",$.innerHTML=s,$.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const h=i.dataset.id,d=i.closest("tr"),A=d.querySelector(".auto-schedule").value,F=d.querySelector(".auto-rollback").checked,j=d.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(h)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!A,schedule:A||"weekly",autoRollback:F,maintenanceWindow:j||void 0})})).json();if(U.success)i.textContent="\u2713 Saved";else throw new Error(U.error)}catch(_){i.textContent="\u2717 Error",showNotification("Save error: "+_.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(p){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const E=document.getElementById("dashcaddy-current-version"),R=document.getElementById("dashcaddy-update-badge"),O=document.getElementById("dashcaddy-update-details"),D=document.getElementById("dashcaddy-new-version"),N=document.getElementById("dashcaddy-changelog"),M=document.getElementById("dashcaddy-apply-btn"),H=document.getElementById("dashcaddy-check-btn"),S=document.getElementById("dashcaddy-rollback-btn"),T=document.getElementById("dashcaddy-status-bar"),b=document.getElementById("dashcaddy-history-container");let u=null;function y(p,m){T&&(T.style.display="block",T.style.background=m==="error"?"var(--bad-bg)":m==="success"?"var(--ok-bg)":"var(--bg)",T.style.color=m==="error"?"var(--bad-fg)":m==="success"?"var(--ok-fg)":"var(--fg)",T.textContent=p)}async function c(){try{const m=await(await fetch("/api/v1/system/version")).json();m.success&&(E.textContent="v"+m.version+(m.commit?" ("+m.commit.substring(0,7)+")":""))}catch{E.textContent="Unable to fetch version"}}async function o(p){p||(H.textContent="Checking...",H.disabled=!0);try{const n=await(await fetch("/api/v1/system/update-check")).json();if(u=n,n.success&&n.available&&n.remote){R.style.display="",O.style.display="",D.textContent="v"+n.remote.version,N.textContent=n.remote.changelog||"No changelog available.";const e=document.getElementById("updates-btn");if(e&&!e.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",e.style.position="relative",e.appendChild(t)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(t)}}else R.style.display="none",O.style.display="none",await c(),p||y("You are running the latest version.","success");p||(H.textContent="Check for Updates",H.disabled=!1)}catch(m){p||(y("Failed to check: "+m.message,"error"),H.textContent="Check for Updates",H.disabled=!1)}}async function l(){if(confirm("Apply DashCaddy update? The API container will restart.")){M.textContent="Updating...",M.disabled=!0,y("Downloading and applying update...","info");try{const m=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(m.success)y("Update initiated: v"+(m.fromVersion||"?")+" \u2192 v"+(m.toVersion||"?")+". The container will restart shortly.","success"),M.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(n=>n.remove());else throw new Error(m.error||"Update failed")}catch(p){y("Update failed: "+p.message,"error"),M.textContent="Update Now",M.disabled=!1}}}async function v(){try{const m=await(await fetch("/api/v1/system/update-history")).json(),n=m.success&&m.history?m.history:[];if(n.length===0){b.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--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 n){const t=a.status==="success"?"\u2713 success":a.status==="pending"?"\u23F3 pending":a.status==="partial"?"\u26A0 partial":"\u2717 "+a.status,s=a.status==="success"?"var(--ok-fg)":a.status==="pending"?"var(--muted)":"var(--bad-fg)";e+='<tr style="border-bottom: 1px solid var(--border);">',e+='<td style="padding: 6px; color: var(--muted);">'+timeAgo(a.timestamp)+"</td>",e+='<td style="padding: 6px; font-weight: 500;">v'+escapeHtml(a.version)+(a.rollback?" (rollback)":"")+"</td>",e+='<td style="padding: 6px; color: var(--muted);">v'+escapeHtml(a.fromVersion||"?")+"</td>",e+='<td style="padding: 6px;"><span style="color: '+s+';">'+t+"</span></td>",e+="</tr>",a.error&&(e+='<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">'+escapeHtml(a.error)+"</td></tr>"),a.note&&(e+='<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--muted);">'+escapeHtml(a.note)+"</td></tr>")}e+="</table>",b.innerHTML=e}catch(p){b.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(p.message)+"</div>"}}async function r(){try{const m=await(await fetch("/api/v1/system/rollback-versions")).json(),n=m.success&&m.versions?m.versions:[];if(n.length===0){showNotification("No rollback versions available.","info");return}const e=prompt(`Available rollback versions:
|
|
`+n.join(`
|
|
`)+`
|
|
|
|
Enter version to rollback to:`);if(!e)return;if(!n.includes(e)){showNotification("Invalid version: "+e,"error");return}if(!confirm("Rollback DashCaddy to v"+e+"? The container will restart."))return;y("Rolling back to v"+e+"...","info");const t=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:e})})).json();if(t.success)y("Rollback to v"+e+" initiated. Container will restart.","success");else throw new Error(t.error||"Rollback failed")}catch(p){y("Rollback failed: "+p.message,"error")}}H?.addEventListener("click",()=>o(!1)),M?.addEventListener("click",l),S?.addEventListener("click",r),I?.addEventListener("click",k),B?.addEventListener("click",()=>{w?.classList.add("show"),g()}),wireModal(w,z),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",f),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{c(),v(),u||o(!0)}),setTimeout(()=>o(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
|
<h3>\u{1F433} Docker Resources</h3>
|
|
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
|
|
|
|
<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 w=document.getElementById("docker-resources-modal"),B=document.getElementById("docker-resources-btn"),z=document.getElementById("dr-close");function I(L){if(!L||L===0)return"0 B";const g=["B","KB","MB","GB","TB"],k=Math.floor(Math.log(Math.abs(L))/Math.log(1024));return(L/Math.pow(1024,k)).toFixed(1)+" "+g[k]}async function C(){const L=document.getElementById("dr-vol-list");try{const k=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(k.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of k){const E=x.name==="buildkit"||x.name.length===64;f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,f+='<td style="padding: 6px; text-align: right;">',E||(f+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",L.innerHTML=f,L.querySelectorAll(".dr-vol-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete volume "${x.dataset.name}"? Data will be lost.`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(x.dataset.name)}?force=true`),C()}catch(E){showNotification("Delete failed: "+E.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const L=document.getElementById("dr-vol-name"),g=L.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),L.value="",showNotification(`Volume "${g}" created`,"success"),C()}catch(k){showNotification("Create failed: "+k.message,"error")}});async function P(){const L=document.getElementById("dr-net-list");try{const k=(await getJSON("/api/v1/docker/networks")).networks||[];if(k.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of k){const E=["bridge","host","none"].includes(x.name);f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,f+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,f+='<td style="padding: 6px; text-align: right;">',E||(f+=`<button class="dr-net-del" data-id="${escapeHtml(x.id)}" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",L.innerHTML=f,L.querySelectorAll(".dr-net-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete network "${x.dataset.name}"?`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(x.dataset.id)}`),P()}catch(E){showNotification("Delete failed: "+E.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const L=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),k=L.value.trim();if(!k){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:k,driver:g.value}),L.value="",showNotification(`Network "${k}" created`,"success"),P()}catch(f){showNotification("Create failed: "+f.message,"error")}});async function $(){const L=document.getElementById("dr-disk-content");try{const g=await getJSON("/api/v1/docker/disk-usage"),k=[{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 f=`<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 16px;">Total: ${I(g.totalSize)}</div>`;f+='<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">';for(const x of k)f+='<div style="padding: 14px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border);">',f+=`<div style="font-weight: 600; margin-bottom: 6px;">${x.icon} ${x.label} <span style="color: var(--muted); font-weight: 400; font-size: 0.82rem;">(${x.count})</span></div>`,f+=`<div style="font-size: 1.1rem; font-weight: 600; color: var(--accent);">${I(x.size)}</div>`,x.reclaimable>0&&(f+=`<div style="font-size: 0.78rem; color: var(--muted);">Reclaimable: ${I(x.reclaimable)}</div>`),x.extra&&(f+=`<div style="font-size: 0.78rem; color: var(--muted);">${x.extra}</div>`),f+="</div>";f+="</div>",L.innerHTML=f}catch(g){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}B?.addEventListener("click",()=>{w?.classList.add("show"),C()}),wireModal(w,z),document.querySelector('[data-panel="dr-networks"]')?.addEventListener("click",P),document.querySelector('[data-panel="dr-disk"]')?.addEventListener("click",$)})(),(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 w=document.getElementById("compose-import-modal"),B=document.getElementById("compose-import-btn"),z=document.getElementById("compose-cancel");wireModal(w,z);let I=null;function C($){document.getElementById("compose-step-paste").style.display=$==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=$==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=$==="progress"?"":"none"}B?.addEventListener("click",()=>{C("paste"),I=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",w?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",$=>{const L=$.target.files[0];if(!L)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(L)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const $=document.getElementById("compose-yaml").value.trim(),L=document.getElementById("compose-stack-name").value.trim()||"stack";if(!$){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),k=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const f=await postJSON("/api/v1/apps/import-compose",{yaml:$,stackName:L});I=f,I.stackName=L,P(f),C("preview")}catch(f){showNotification("Parse failed: "+f.message,"error")}finally{g.textContent=k,g.disabled=!1}});function P($){const L=document.getElementById("compose-preview-content");let g="";$.networks&&$.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${$.networks.map(k=>`<code>${escapeHtml(k)}</code>`).join(", ")}</div>`),$.volumes&&$.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${$.volumes.map(k=>`<code>${escapeHtml(k)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${$.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const k of $.services){const f=k.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${f}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(k.name)}`,k.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(k.reason)}</span>`),g+="</div>",!k.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(k.image)}</code></div>`,k.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${k.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),k.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${k.volumes.length}</div>`),Object.keys(k.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(k.environment).length}</div>`),k.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(k.envFileWarning)}</div>`),k.resources?.cpus||k.resources?.memory)){const x=[];k.resources.cpus&&x.push(`CPU: ${k.resources.cpus}`),k.resources.memory&&x.push(`Mem: ${k.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}g+="</div>"}g+="</div>",L.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>C("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!I)return;const $=document.getElementById("compose-deploy-btn");$.textContent="Deploying...",$.disabled=!0,C("progress");const L=document.getElementById("compose-progress-content");L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:I.services,networks:I.networks,stackName:I.stackName});let k=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;k+='<div class="scroll-container" style="max-height: 350px;">';for(const f of g.results){const x=f.status==="deployed"||f.status==="created"?"\u2705":f.status==="exists"?"\u26A1":f.status==="skipped"?"\u23ED":"\u274C";k+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',k+=`${x} <strong>${escapeHtml(f.name)}</strong> (${f.type}) \u2014 ${escapeHtml(f.status)}`,f.error&&(k+=` <span style="color: var(--bad-fg);">${escapeHtml(f.error)}</span>`),f.subdomain&&(k+=` \u2192 <code>${escapeHtml(f.subdomain)}</code>`),f.reason&&(k+=` <span style="color: var(--muted);">(${escapeHtml(f.reason)})</span>`),k+="</div>"}k+="</div>",k+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>',L.innerHTML=k,document.getElementById("compose-done-btn")?.addEventListener("click",()=>{w?.classList.remove("show"),typeof window.loadServices=="function"&&window.loadServices().then(()=>{typeof window.buildGrid=="function"&&window.buildGrid()})}),showNotification(`Stack "${g.stackName}" deployed`,"success")}catch(g){L.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",()=>C("paste"))}finally{$.textContent="Deploy All",$.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
|
<h3 id="exec-title">Terminal</h3>
|
|
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
|
<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 w=document.getElementById("exec-modal"),B=document.getElementById("exec-terminal"),z=document.getElementById("exec-close");let I=null,C=null,P=null;function $(){if(C){try{C.close()}catch{}C=null}if(I){try{I.dispose()}catch{}I=null}P=null,B.innerHTML=""}function L(g,k){if($(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${k||g}`,w?.classList.add("show"),typeof Terminal>"u"){B.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}I=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,I.loadAddon(P)),I.open(B),P&&setTimeout(()=>P.fit(),50);const f=location.protocol==="https:"?"wss:":"ws:";C=new WebSocket(`${f}//${location.host}/ws/exec/${encodeURIComponent(g)}`),C.binaryType="arraybuffer",C.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),P){const E=P.proposeDimensions();E&&C.send(JSON.stringify({type:"resize",cols:E.cols,rows:E.rows}))}},C.onmessage=E=>{if(typeof E.data=="string"){try{const R=JSON.parse(E.data);if(R.type==="connected"){I.writeln(`\x1B[32mConnected (${R.shell})\x1B[0m\r
|
|
`);return}if(R.type==="error"){I.writeln(`\x1B[31mError: ${R.message}\x1B[0m`);return}if(R.type==="exit"){I.writeln(`\r
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(E.data)}else I.write(new Uint8Array(E.data))},C.onclose=()=>{I&&I.writeln(`\r
|
|
\x1B[33mDisconnected.\x1B[0m`)},C.onerror=()=>{I&&I.writeln(`\r
|
|
\x1B[31mConnection error.\x1B[0m`)},I.onData(E=>{C&&C.readyState===WebSocket.OPEN&&C.send(E)}),I.onResize(({cols:E,rows:R})=>{C&&C.readyState===WebSocket.OPEN&&C.send(JSON.stringify({type:"resize",cols:E,rows:R}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),w._resizeHandler=x}z?.addEventListener("click",()=>{$(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show")}),w?.addEventListener("click",g=>{g.target===w&&($(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show"))}),window.openExecModal=L})(),(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 w=document.getElementById("audit-modal"),B=document.getElementById("audit-log-btn"),z=document.getElementById("audit-cancel"),I=document.getElementById("audit-refresh-btn"),C=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),$=document.getElementById("audit-log-container"),L=document.getElementById("audit-load-more");let g=0;const k=50;async function f(x){try{x||(g=0,$.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const E=P.value;let R=`/api/v1/audit-logs?limit=${k}&offset=${g}`;E&&(R+=`&action=${encodeURIComponent(E)}`);const D=await(await fetch(R)).json(),N=D.success&&D.entries?D.entries:[];if(N.length===0&&!x){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',L.style.display="none";return}let M="";x||(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 H of N){const S=H.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(H.timestamp)}</td>`,M+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(H.ip||"-")}</td>`,M+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.action||"-")}</td>`,M+=`<td style="padding: 6px;">${escapeHtml(H.resource||"-")}</td>`,M+=`<td style="padding: 6px;"><span style="color: ${S?"var(--ok-fg)":"var(--bad-fg)"};">${S?"\u2713":"\u2717"}</span></td>`,M+="</tr>",H.details&&Object.keys(H.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(H.details,null,2))}</pre></td></tr>`)}if(!x)M+="</table>",$.innerHTML=M;else{const H=$.querySelector("table");H&&H.insertAdjacentHTML("beforeend",M)}g+=N.length,L.style.display=N.length>=k?"":"none",$.querySelectorAll(".audit-row").forEach(H=>{H.dataset.wired||(H.dataset.wired="true",H.addEventListener("click",()=>{const S=H.nextElementSibling;S&&S.classList.contains("audit-detail")&&(S.style.display=S.style.display==="none"?"":"none")}))})}catch(E){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(E.message)}</div>`}}B?.addEventListener("click",()=>{w?.classList.add("show"),f(!1)}),wireModal(w,z),I?.addEventListener("click",()=>f(!1)),P?.addEventListener("change",()=>f(!1)),L?.addEventListener("click",()=>f(!0)),C?.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?f(!1):showNotification("Error: "+(E.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){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 w="weather-location",B="weather-zip",z="weather-geo",I="weather-unit";!safeGet(w)&&safeGet(B)&&safeSet(w,safeGet(B));function C(){return safeGet(I)||"imperial"}function P(){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 $={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"},L={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"},g=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(N){return g[Math.round(N/22.5)%16]}async function f(N){const M=safeGet(z);if(M)try{const u=JSON.parse(M);if(u.query===N)return u}catch{}const H=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(N)}&count=1&language=en&format=json`);if(!H.ok)throw new Error("Geocoding failed");const S=await H.json();if(!S.results||!S.results.length)throw new Error("Location not found");const T=S.results[0],b={query:N,lat:T.latitude,lon:T.longitude,city:T.name,state:T.admin1||"",country:T.country||"",countryCode:T.country_code||""};return safeSet(z,JSON.stringify(b)),b}function x(N){return N.countryCode==="US"&&N.state?`${N.city}, ${N.state}`:N.country?`${N.city}, ${N.country}`:N.city}async function E(N){try{const M=await f(N),H=C(),S=H==="metric"?"celsius":"fahrenheit",T=H==="metric"?"kmh":"mph",b=`https://api.open-meteo.com/v1/forecast?latitude=${M.lat}&longitude=${M.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${S}&wind_speed_unit=${T}`,u=await fetch(b);if(!u.ok)throw new Error("Weather fetch failed");const c=(await u.json()).current,o=c.weather_code;return{temp:Math.round(c.temperature_2m),condition:$[o]||"Unknown",icon:L[o]||"\u{1F324}\uFE0F",locationStr:x(M),windSpeed:Math.round(c.wind_speed_10m),windDir:k(c.wind_direction_10m),unit:H}}catch(M){return console.warn("Weather fetch failed:",M),null}}async function R(){const N=P();if(!N.icon||!N.temp||!N.condition||!N.location||!N.wind){console.warn("Weather widget elements not found");return}const M=safeGet(w);if(!M){N.location.textContent="Set Location",N.temp.textContent="--\xB0",N.condition.textContent="Click \u2699\uFE0F to configure",N.wind.textContent="--",N.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const H=await E(M);if(H){const S=H.unit==="metric"?"\xB0C":"\xB0F",T=H.unit==="metric"?"km/h":"mph";N.location.textContent=H.locationStr,N.temp.textContent=`${H.temp}${S}`,N.condition.textContent=H.condition,N.wind.textContent=`Wind: ${H.windSpeed} ${T} ${H.windDir}`,N.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(H.icon)}</span>`}}catch(H){console.error("Weather update error:",H),N.location.textContent="Weather Error",N.temp.textContent="Error",N.condition.textContent="Failed to load",N.wind.textContent="--"}}const O=document.getElementById("weather-modal"),D=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{D.value=safeGet(w)||"";const N=C(),M=O.querySelector(`input[name="weather-unit-radio"][value="${N}"]`);M&&(M.checked=!0),O.classList.add("show"),D.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{O.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const N=D.value.trim();if(N){safeGet(w)!==N&&safeSet(z,""),safeSet(w,N);const H=O.querySelector('input[name="weather-unit-radio"]:checked'),S=H?H.value:"imperial",T=C();safeSet(I,S),T!==S&&safeSet(z,""),O.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(O),document.addEventListener("keydown",N=>{N.key==="Escape"&&O.classList.contains("show")&&O.classList.remove("show")}),R(),setInterval(R,DC.POLL.WEATHER)})(),(function(){const w=document.getElementById("clock-widget"),B=document.getElementById("clock-render");if(!w||!B)return;const z=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],I=["January","February","March","April","May","June","July","August","September","October","November","December"],C=["XII","I","II","III","IV","V","VI","VII","VIII","IX","X","XI"];let P=safeGet("clock-style")||"default",$=-1,L=!1,g="",k="",f=null,x=null;function E(n){if(L||safeGet("clock-chimes")!=="true")return;L=!0;const e=parseInt(safeGet("clock-chime-volume")||"50",10)/100;let a=0;function t(){if(a>=n){L=!1;return}const s=new Audio("/assets/sounds/church-bell.mp3");s.volume=e,s.play().catch(()=>{}),a++,a<n?setTimeout(t,2500):setTimeout(()=>{L=!1},2500)}t()}function R(n){return z[n.getDay()]+", "+I[n.getMonth()]+" "+n.getDate()+", "+n.getFullYear()}function O(){k="",f=null}function D(){return k!=="digital"&&(B.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>',f={main:B.querySelector(".clock-main"),seconds:B.querySelector(".clock-seconds"),ampm:B.querySelector(".clock-ampm"),date:B.querySelector(".clock-date")},k="digital"),f}function N(n){const e=n.getHours(),a=n.getMinutes(),t=n.getSeconds(),s=e>=12?"PM":"AM",i=e%12||12,h=D();h.main.textContent=`${i}:${String(a).padStart(2,"0")}`,h.seconds.textContent=`:${String(t).padStart(2,"0")}`,h.ampm.textContent=s,h.date.textContent=R(n)}function M(n,e){const a=n.getHours(),t=n.getMinutes(),s=n.getSeconds(),i=a>=12?"PM":"AM",h=a%12||12,d=D();d.main.textContent=`${String(h).padStart(2,"0")}:${String(t).padStart(2,"0")}`,d.seconds.textContent=`:${String(s).padStart(2,"0")}`,d.ampm.textContent=i,d.date.textContent=R(n)}function H(n){const e=n.getHours(),a=n.getMinutes(),t=n.getSeconds(),s=e>=12?"PM":"AM",i=e%12||12,h=String(i).padStart(2," ")+String(a).padStart(2,"0")+String(t).padStart(2,"0");let d='<div class="flip-clock-row">';if(d+=S(h[0],0),d+=S(h[1],1),d+='<span class="flip-colon">:</span>',d+=S(h[2],2),d+=S(h[3],3),d+='<span class="flip-colon">:</span>',d+=S(h[4],4),d+=S(h[5],5),d+=`<span class="flip-ampm">${s}</span>`,d+="</div>",d+=`<div class="clock-date">${R(n)}</div>`,B.innerHTML=d,k="flip",g){for(let A=0;A<6;A++)if(h[A]!==g[A]){const F=B.querySelector(`.flip-card[data-idx="${A}"]`);F&&F.classList.add("flipping")}}g=h}function S(n,e){const a=n===" "?"":n;return`<div class="flip-card" data-idx="${e}"><div class="flip-top">${a}</div><div class="flip-bottom">${a}</div></div>`}function T(n){const e=n.getHours(),a=n.getMinutes(),t=n.getSeconds(),s=e%12||12,i=e>=12?"PM":"AM",h=[Math.floor(s/10),s%10,Math.floor(a/10),a%10,Math.floor(t/10),t%10];let d='<div class="binary-clock">';d+='<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 A=3;A>=0;A--){d+='<div class="binary-row">';for(let F=0;F<6;F++){const j=h[F]>>A&1;d+=`<div class="binary-dot ${j?"on":""}"></div>`}d+="</div>"}d+='<div class="binary-values">';for(let A=0;A<6;A++)d+=`<span>${h[A]}</span>`;d+="</div>",d+=`<div class="binary-ampm">${i}</div>`,d+="</div>",d+=`<div class="clock-date">${R(n)}</div>`,B.innerHTML=d,k="binary"}function b(n,e){const a=n.getHours(),t=n.getMinutes(),s=n.getSeconds(),i=120,h=i/2,d=i/2,A=s/60*360-90,F=(t+s/60)/60*360-90,j=(a%12+t/60)/12*360-90;let _="";for(let G=1;G<=12;G++){const Q=G/12*2*Math.PI-Math.PI/2,ae=47,ne=h+ae*Math.cos(Q),X=d+ae*Math.sin(Q),se=e?C[G%12]:G;_+=`<text x="${ne}" y="${X}" text-anchor="middle" dominant-baseline="central" fill="var(--fg)" font-size="${e?"7":"9"}" font-weight="600" font-family="'Sami Grotesk',sans-serif">${se}</text>`}let U="";for(let G=0;G<60;G++){const Q=G/60*2*Math.PI-Math.PI/2,ae=56,ne=G%5===0?52:54,X=h+ne*Math.cos(Q),se=d+ne*Math.sin(Q),ie=h+ae*Math.cos(Q),oe=d+ae*Math.sin(Q),q=G%5===0?1.5:.5;U+=`<line x1="${X}" y1="${se}" x2="${ie}" y2="${oe}" stroke="var(--muted)" stroke-width="${q}" stroke-linecap="round"/>`}const W=`<svg class="analog-clock-svg" viewBox="0 0 ${i} ${i}" width="${i}" height="${i}">
|
|
<circle cx="${h}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
${U}
|
|
${_}
|
|
<line x1="${h}" y1="${d}" x2="${h+28*Math.cos(j*Math.PI/180)}" y2="${d+28*Math.sin(j*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
<line x1="${h}" y1="${d}" x2="${h+38*Math.cos(F*Math.PI/180)}" y2="${d+38*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
<line x1="${h}" y1="${d}" x2="${h+42*Math.cos(A*Math.PI/180)}" y2="${d+42*Math.sin(A*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
<circle cx="${h}" cy="${d}" r="3" fill="var(--fg)"/>
|
|
</svg>`,Z=n.getHours()>=12?"PM":"AM";B.innerHTML=`<div class="analog-clock-wrap">${W}<div class="analog-info"><span class="analog-digital">${n.getHours()%12||12}:${String(t).padStart(2,"0")} ${Z}</span><span class="analog-date-sm">${R(n)}</span></div></div>`,k="analog"}function u(){const n=new Date,e=n.getHours()%12||12,a=n.getMinutes(),t=n.getSeconds(),s="clock-widget"+(P!=="default"?" "+P:"");switch(w.className!==s&&(w.className=s),P){case"lcd":M(n);break;case"lcd-blue":M(n);break;case"lcd-amber":M(n);break;case"lcd-retro":M(n);break;case"lcd-taxi":M(n);break;case"flip":H(n);break;case"binary":T(n);break;case"analog":b(n,!1);break;case"roman":b(n,!0);break;default:N(n)}a===0&&t===0&&e!==$&&($=e,E(e)),a!==0&&($=-1)}function y(){clearTimeout(x);const n=document.hidden?6e4:1e3,e=n-Date.now()%n+25;x=setTimeout(()=>{u(),y()},e)}document.addEventListener("visibilitychange",()=>{g="",O(),u(),y()}),u(),y();const c=[{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 o='<div class="clock-style-grid">';c.forEach(n=>{o+=`<label class="clock-style-option">
|
|
<input type="radio" name="clock-style-radio" value="${n.id}">
|
|
<span class="clock-style-card"><span class="clock-style-icon">${n.icon}</span><span class="clock-style-label">${n.label}</span></span>
|
|
</label>`}),o+="</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>
|
|
${o}
|
|
</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 l=document.getElementById("clock-settings-modal"),v=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function m(){const n=safeGet("clock-style")||"default",e=l.querySelector(`input[value="${n}"]`);e&&(e.checked=!0),v.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=v.checked?"1":"0.4"}v?.addEventListener("change",()=>{p.style.opacity=v.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{m(),l.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(r.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=n,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const n=l.querySelector('input[name="clock-style-radio"]:checked'),e=n?n.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(v.checked)),safeSet("clock-chime-volume",r.value),P=e,g="",O(),u(),y(),l.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{l.classList.remove("show")}),wireModal(l),l?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{P=n.value,g="",O(),u()})})})(),(function(){async function w(){try{const $=await(await fetch("/api/v1/health-checks/status")).json();if(!$.success||!$.status)return;for(const[L,g]of Object.entries($.status)){const k=document.getElementById("uptime-"+L),f=document.getElementById("uptime-bar-"+L);if(!k)continue;const x=g.uptime?.["24h"];if(x!=null){const E=x.toFixed(1);k.textContent=`${E}% uptime`,k.className="uptime-chip",x>=99.9?k.classList.add("excellent"):x>=99?k.classList.add("good"):x>=95?k.classList.add("degraded"):k.classList.add("poor"),f&&(f.style.width=E+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let B;try{B=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{B=new Set}async function z(){try{const $=await(await fetch("/api/v1/updates/available")).json();if(!$.success||(document.querySelectorAll(".update-available-badge").forEach(L=>L.classList.remove("visible")),!$.updates?.length))return;for(const L of $.updates){const g=window.APPS||[];for(const k of g)if(k.containerId===L.containerId||k.id===L.containerName||k.name===L.containerName){if(B.has(k.id))break;const f=document.getElementById("update-badge-"+k.id);f&&(f.classList.add("visible"),f.title=`Image digest changed. Click to dismiss if already up to date.
|
|
${L.imageName||""}`,f.style.cursor="pointer",f.onclick=x=>{x.stopPropagation(),f.classList.remove("visible"),B.add(k.id),safeSessionSet("dismissed-updates",JSON.stringify([...B]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function I(){setTimeout(()=>{w(),z()},5e3),setInterval(()=>{w(),z()},6e4)}const C=window.refreshAll;C&&(window.refreshAll=async function(){try{await C(),setTimeout(w,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),I()})(),(function(){var w=null,B=null,z={},I={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},C=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var $=document.getElementById("theme-label");function L(t){if(I[t])return I[t];var s=safeGetJSON(window.USER_THEMES_KEY,{});return s[t]&&s[t].name||t}function g(){$&&($.textContent=L(window.getActiveTheme()))}P.addEventListener("click",function(){var t=window.THEMES.slice(),s=window.getActiveTheme(),i=t.indexOf(s),h=t[(i+1)%t.length];window.applyTheme(h),g()}),g();function k(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},s={};C.forEach(function(h){s[h[2]]||(s[h[2]]=[]),s[h[2]].push(h)});var i="";return Object.keys(t).forEach(function(h){h==="advanced"?(i+='<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>',i+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):i+='<div class="theme-builder-section">',i+='<div class="theme-builder-section-title">'+t[h]+"</div>",(s[h]||[]).forEach(function(d){i+='<div class="theme-builder-row"><span class="theme-builder-label">'+d[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+d[0]+'"'+(h==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+d[0]+'">#000000</span></div>'}),i+="</div>"}),i}function f(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+L(t)+"</option>"}).join("")}var x='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+f()+'</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>'+k()+'<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",x);var E=document.getElementById("theme-builder-modal"),R=document.getElementById("theme-builder-start"),O=document.getElementById("theme-builder-name"),D=document.getElementById("theme-builder-edit-select"),N=document.getElementById("theme-builder-existing"),M=document.getElementById("theme-builder-lightbg"),H=E.querySelectorAll(".theme-builder-color"),S=document.getElementById("theme-builder-advanced"),T=document.getElementById("theme-builder-advanced-toggle"),b=document.getElementById("theme-builder-export"),u=!1;T.addEventListener("click",function(){u=!u,S.style.display=u?"":"none",T.innerHTML=u?"Hide advanced colors ▲":"Show advanced colors ▼"});function y(){var t={};return H.forEach(function(s){t[s.dataset.prop]=s.value}),t["card-bg"]=t["card-base"],t}function c(){var t={};return H.forEach(function(s){s.dataset.advanced||(t[s.dataset.prop]=s.value)}),t["card-bg"]=t["card-base"],M.checked&&(t.lightBg=!0),t}function o(){var t=c(),s=window.deriveExtendedColors?window.deriveExtendedColors(t):{};H.forEach(function(i){if(i.dataset.advanced&&!z[i.dataset.prop]){var h=s[i.dataset.prop]||"#333333";i.value=h;var d=E.querySelector('[data-hex="'+i.dataset.prop+'"]');d&&(d.textContent=h.toUpperCase())}})}function l(t){var s=window.THEME_COLORS[t];s&&(z={},H.forEach(function(i){var h=s[i.dataset.prop]||"#000000";(h.startsWith("rgba")||h.startsWith("color-mix"))&&(h="#333333"),i.value=h;var d=E.querySelector('[data-hex="'+i.dataset.prop+'"]');d&&(d.textContent=h.toUpperCase())}),M.checked=!!s.lightBg,r())}function v(t){var s=safeGetJSON(window.USER_THEMES_KEY,{}),i=s[t];i&&(z={},H.forEach(function(h){var d=i[h.dataset.prop]||"#000000";h.value=d;var A=E.querySelector('[data-hex="'+h.dataset.prop+'"]');A&&(A.textContent=d.toUpperCase()),h.dataset.advanced&&i[h.dataset.prop]&&(z[h.dataset.prop]=!0)}),O.value=i.name||"",M.checked=!!i.lightBg,o(),r())}function r(){var t=y(),s=document.getElementById("theme-builder-preview"),i=document.getElementById("theme-preview-card"),h=i.querySelector(".preview-title"),d=i.querySelector(".preview-muted"),A=document.getElementById("preview-badge-ok"),F=document.getElementById("preview-badge-bad"),j=document.getElementById("preview-dot-ok"),_=document.getElementById("preview-dot-bad"),U=document.getElementById("preview-accent-btn"),W=i.querySelector(".preview-dots");s.style.background=t.bg,i.style.background=t["card-base"],i.style.borderColor=t.border,h.style.color=t.fg,d.style.color=t.muted,A.style.background=t["ok-bg"],A.style.color=t["ok-fg"],F.style.background=t["bad-bg"],F.style.color=t["bad-fg"],j.style.background=t["dot-ok"],_.style.background=t["dot-bad"],W.style.color=t.fg,U.style.background=t.accent,U.style.color=t.bg}function p(t){var s=window.deriveExtendedColors?window.deriveExtendedColors(t):{};window.THEME_PROPS.forEach(function(i){var h=t[i]||s[i];h&&document.documentElement.style.setProperty("--"+i,h)})}H.forEach(function(t){t.addEventListener("input",function(){var s=E.querySelector('[data-hex="'+t.dataset.prop+'"]');s&&(s.textContent=t.value.toUpperCase()),t.dataset.advanced?z[t.dataset.prop]=!0:o(),r(),p(y())})}),M.addEventListener("change",function(){o(),r(),p(y())}),R.addEventListener("change",function(){z={},l(R.value),p(y())});function m(){var t=safeGetJSON(window.USER_THEMES_KEY,{}),s=Object.keys(t);D.innerHTML='<option value="">\u2014 New Theme \u2014</option>',s.forEach(function(i){var h=document.createElement("option");h.value=i,h.textContent=t[i].name||i,D.appendChild(h)}),N.style.display=s.length?"":"none"}function n(){R.innerHTML=f()}D.addEventListener("change",function(){var t=this.value;t?(B=t,v(t),p(y())):(B=null,z={},O.value="",R.value=window.getActiveTheme(),l(R.value)),b.style.display=B?"":"none"});function e(){w=window.getActiveTheme(),B=null,z={},u=!1,S.style.display="none",T.innerHTML="Show advanced colors ▼",m(),n();var t=safeGetJSON(window.USER_THEMES_KEY,{});t[w]?(D.value=w,B=w,v(w)):(D.value="",O.value="",R.value=w,l(w)),b.style.display=B?"":"none",E.classList.add("show")}var a=document.getElementById("theme-customize-btn");a&&a.addEventListener("click",function(){e()}),document.getElementById("theme-builder-save").addEventListener("click",function(){var t=y(),s=O.value.trim();if(!s){showNotification("Please enter a theme name","warning",3e3),O.focus();return}var i=safeGetJSON(window.USER_THEMES_KEY,{}),h,d=null;if(B){h=B;var A=window.slugifyThemeName(s,B);if(A!==B){d=B,delete i[B];var F=window.THEMES.indexOf(B);F!==-1&&window.THEMES.splice(F,1),delete window.THEME_COLORS[B],h=A}}else h=window.slugifyThemeName(s);var j={name:s};M.checked&&(j.lightBg=!0),window.THEME_PROPS.forEach(function(U){t[U]&&(j[U]=t[U])}),i[h]=j,safeSet(window.USER_THEMES_KEY,JSON.stringify(i)),window.clearCustomProperties(),window.injectUserThemeStyles(),window.applyTheme(h),E.classList.remove("show"),g();var _={};window.THEME_PROPS.forEach(function(U){t[U]&&(_[U]=t[U])}),d&&secureFetch("/api/v1/themes/"+d,{method:"DELETE"}).catch(function(){}),secureFetch("/api/v1/themes/"+h,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:s,colors:_,lightBg:M.checked})}).then(function(){showNotification(s+" theme saved","success",3e3)}).catch(function(){showNotification(s+" theme saved locally (server sync failed)","warning",3e3)})}),document.getElementById("theme-builder-cancel").addEventListener("click",function(){E.classList.remove("show"),window.clearCustomProperties(),w&&window.applyTheme(w)}),document.getElementById("theme-builder-delete").addEventListener("click",function(){if(B){var t=safeGetJSON(window.USER_THEMES_KEY,{}),s=t[B]?t[B].name:B;if(confirm('Delete "'+s+'" theme?')){var i=B;delete t[i],safeSet(window.USER_THEMES_KEY,JSON.stringify(t));var h=window.THEMES.indexOf(i);h!==-1&&window.THEMES.splice(h,1),delete window.THEME_COLORS[i],window.clearCustomProperties(),window.injectUserThemeStyles();var d=w&&w!==i?w:"dark";window.applyTheme(d),B=null,E.classList.remove("show"),g(),secureFetch("/api/v1/themes/"+i,{method:"DELETE"}).then(function(){showNotification(s+" theme deleted","success",3e3)}).catch(function(){showNotification(s+" theme deleted locally (server sync failed)","warning",3e3)})}}}),document.getElementById("theme-builder-export").addEventListener("click",function(){if(!B){showNotification("Save the theme first, then export","warning",3e3);return}var t=safeGetJSON(window.USER_THEMES_KEY,{}),s=t[B];if(s){var i={_dashcaddy_theme:!0,version:"1.0",exportDate:new Date().toISOString(),slug:B,name:s.name,lightBg:s.lightBg||!1,colors:{}};window.THEME_PROPS.forEach(function(F){s[F]&&(i.colors[F]=s[F])});var h=new Blob([JSON.stringify(i,null,2)],{type:"application/json"}),d=URL.createObjectURL(h),A=document.createElement("a");A.href=d,A.download=B+"-theme.json",document.body.appendChild(A),A.click(),document.body.removeChild(A),URL.revokeObjectURL(d),showNotification("Theme exported as "+B+"-theme.json","success",3e3)}}),document.getElementById("theme-builder-import").addEventListener("click",function(){var t=document.createElement("input");t.type="file",t.accept=".json",t.onchange=function(s){var i=s.target.files[0];if(i){var h=new FileReader;h.onload=function(d){try{var A=JSON.parse(d.target.result),F,j,_;if(A._dashcaddy_theme&&A.colors)F=A.name||"Imported",j=A.colors,_=A.lightBg||!1;else if(A.name&&(A.bg||A["card-base"]))F=A.name,j={},window.THEME_PROPS.forEach(function(U){A[U]&&(j[U]=A[U])}),_=A.lightBg||!1;else throw new Error("Not a valid DashCaddy theme file");O.value=F,M.checked=!!_,B=null,D.value="",z={},H.forEach(function(U){var W=j[U.dataset.prop]||"#000000";U.value=W;var Z=E.querySelector('[data-hex="'+U.dataset.prop+'"]');Z&&(Z.textContent=W.toUpperCase()),U.dataset.advanced&&j[U.dataset.prop]&&(z[U.dataset.prop]=!0)}),o(),r(),p(y()),b.style.display="none",showNotification('"'+F+'" loaded into builder. Click Save to keep it.',"success",5e3)}catch(U){showNotification("Import failed: "+U.message,"error",5e3)}},h.readAsText(i)}},t.click()}),wireModal(E),E.addEventListener("click",function(t){t.target===E&&(window.clearCustomProperties(),w&&window.applyTheme(w))})})(),(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 w=document.getElementById("license-modal"),B=document.getElementById("license-code-input"),z=document.getElementById("license-activate"),I=document.getElementById("license-deactivate"),C=document.getElementById("license-error"),P=document.getElementById("license-success"),$=document.getElementById("license-badge-icon"),L=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),k=document.getElementById("license-details"),f=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let E=null;function R(){C.style.display="none",P.style.display="none"}function O(y){R(),C.textContent=y,C.style.display="block"}function D(y){R(),P.textContent=y,P.style.display="block"}function N(y){if(E=y,y.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",$.textContent="\u2605",L.textContent="Premium Active";const l=y.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(y.expiresAt).toLocaleDateString()}</strong> (${y.daysRemaining} days remaining)</div>`;k.innerHTML=`
|
|
<div>Code: <code style="font-family: monospace;">${y.code||"***"}</code></div>
|
|
${l}
|
|
`,x.style.display="none",z.style.display="none",I.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",$.textContent="\u2606",L.textContent=y.expired?"License Expired":"Free Tier",k.innerHTML=y.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",x.style.display="",z.style.display="",I.style.display="none";const c=y.premiumFeatures||{},o=new Set(y.features||[]);f.innerHTML=Object.entries(c).map(([l,v])=>`<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;">${o.has(l)?"\u2705":"\u{1F512}"}</span>
|
|
<div>
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${v.name}</div>
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${v.description}</div>
|
|
</div>
|
|
</div>`).join("")}async function M(){try{const c=await(await fetch("/api/v1/license/status")).json();c.success&&(N(c.license),T(c.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function H(){const y=B.value.trim();if(!y){O("Please enter a license code.");return}R(),z.disabled=!0,z.textContent="Activating...";try{const o=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();o.success?(D(o.message),B.value="",N(o.license),showNotification("License activated! Premium features unlocked.","success",5e3),T(o.license)):O(o.error||"Activation failed")}catch(c){O("Network error: "+c.message)}finally{z.disabled=!1,z.textContent="Activate"}}async function S(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const c=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();c.success?(D(c.message),await M(),showNotification("License deactivated.","info",3e3),T({active:!1})):O(c.error||"Deactivation failed")}catch(y){O("Network error: "+y.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function T(y){const c=document.getElementById("license-status-topbar"),o=document.getElementById("license-topbar-icon"),l=document.getElementById("license-topbar-text"),v=document.getElementById("license-topbar-time");if(c)if(c.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(o.textContent="\u2605",l.textContent="PREMIUM",y.lifetime)v.textContent="\xB7 LIFETIME";else{const r=y.daysRemaining;v.textContent=r!=null?"\xB7 "+r+"d remaining":""}else o.textContent="\u2606",l.textContent=y.expired?"EXPIRED":"FREE TIER",v.textContent=""}function b(){R(),M(),w.classList.add("show")}B.addEventListener("input",function(){let y=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(y.length>this._prevLength&&(y=y.replace(/-/g,""),y.length>2&&!y.startsWith("DC")&&(y="DC"+y),y.startsWith("DC")&&y.length>2)){const c=["DC"],o=y.substring(2);for(let l=0;l<o.length;l+=5)c.push(o.substring(l,l+5));y=c.join("-")}this._prevLength=y.length,this.value=y}),z.addEventListener("click",H),I.addEventListener("click",S),B.addEventListener("keydown",y=>{y.key==="Enter"&&H()}),wireModal(w,document.getElementById("license-cancel"));const u=document.getElementById("license-status-topbar");u&&u.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=b,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},M().then(y=>{E&&T(E)})})();
|