Three merge-fallout bugs that combined to leave the services grid empty
and most UI inert:
1. error-handler.js was bundled into onboarding.js (loaded 3rd), but
globals.js in core.js (loaded 1st) does `const errorHandler = new
ErrorHandler()` at top level. ErrorHandler was undefined when core.js
ran -> ReferenceError -> globals.js stopped, so window.APPS,
_showTotpOverlay, loadServices, etc. were never set, and init.js
blew up on every call into core's exports.
Moved error-handler.js to the start of the core.js bundle so the
class is on window before any other script touches it.
2. setup-wizard.js also declared `const errorHandler = new ErrorHandler()`
at top level. Classic scripts share the document's top-level lexical
environment, so this collided with globals.js's declaration ->
redeclaration SyntaxError in features.js. Removed setup-wizard.js's
copy; it picks up the global one.
3. tooltip-definitions.js closed its `(function(window){...})(window);`
IIFE at line ~171 ("Validation module loaded"), then the TOOLTIP_
DEFINITIONS array, getter helpers, window.TooltipDefinitions export,
and final `debug(...)` log all sat at top level — outside the IIFE,
where `debug` was no longer in scope. Removed the early close and
added one at EOF so the whole file is in one IIFE.
1680 lines
266 KiB
JavaScript
1680 lines
266 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 b=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),N=document.getElementById("logo-preview-light"),S=document.getElementById("logo-status"),T=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),L=document.getElementById("logo-single-upload"),H=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),I=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),$=document.querySelector(".top-row"),C=document.getElementById("dashboard-title"),R=DC.NAME;let M=null,j=null,B=null,A="left",w=R;T?.addEventListener("change",()=>{T.checked?(P.style.display="none",L.style.display="",M=null,j=null):(P.style.display="flex",L.style.display="none",B=null)});function z(a,e){if(!a||!a.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=t=>e(t.target.result),n.readAsDataURL(a)}H?.addEventListener("change",a=>{z(a.target.files[0],e=>{M=e,E.src=e,S.textContent="New dark logo ready to save"})}),g?.addEventListener("change",a=>{z(a.target.files[0],e=>{j=e,N.src=e,S.textContent="New light logo ready to save"})}),I?.addEventListener("change",a=>{z(a.target.files[0],e=>{B=e,E.src=e,N.src=e,S.textContent="New logo ready to save (both themes)"})});function f(a){$.setAttribute("data-logo-pos",a),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===a?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===a?"white":"var(--fg)"})}function p(a){w=a||R,document.title=w;const e=document.querySelector(".dashboard-title");e&&(e.textContent=w)}async function y(){try{const a=await fetch("/api/v1/logo");if(a.ok){const e=await a.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,N.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,x.src=e.customLogo,E.src=e.customLogo,N.src=e.customLogo),e.isDefault||(S.textContent="Using custom logo"),e.position&&(A=e.position,f(e.position)),e.dashboardTitle&&p(e.dashboardTitle)}}catch(a){console.warn("Could not load custom logo:",a.message)}}document.querySelectorAll(".logo-pos-btn").forEach(a=>{a.addEventListener("click",()=>{A=a.dataset.pos,f(A)})}),document.getElementById("brand")?.addEventListener("click",()=>{M=null,j=null,B=null,H&&(H.value=""),g&&(g.value=""),I&&(I.value=""),T&&(T.checked=!1),P.style.display="flex",L.style.display="none",E.src=k.src,N.src=x.src;const a=k.src.includes("custom-logo")||x.src.includes("custom-logo");S.textContent=a?"Using custom logo":"Using default logos",f(A),C.value=w,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const a=C.value.trim()||R,e={position:A,dashboardTitle:a};T?.checked&&B?(e.dataDark=B,e.dataLight=B):(M&&(e.dataDark=M),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const t=await n.json(),i="?t="+Date.now();t.pathDark&&(k.src=t.pathDark+i,E.src=t.pathDark+i),t.pathLight&&(x.src=t.pathLight+i,N.src=t.pathLight+i),f(A),p(a),b.classList.remove("show")}else{const t=await n.json();showNotification("Failed to save: "+t.error,"error")}}catch(a){showNotification("Error saving: "+a.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(k.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",N.src="/assets/dashcaddy-logo-light.png",S.textContent="Using default logos",M=null,j=null,B=null,C.value=R,p(R),A="left",f("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),s=null}}catch(a){showNotification("Error resetting branding: "+a.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const v=document.getElementById("favicon-preview"),m=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let s=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function h(){try{const a=await fetch("/api/v1/favicon");if(a.ok){const e=await a.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),v.src=e.customFavicon+"?t="+Date.now(),m.textContent="Using custom favicon")}}catch(a){console.warn("Could not load custom favicon:",a.message)}}r?.addEventListener("change",a=>{const e=a.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const n=new FileReader;n.onload=t=>{s=t.target.result,v.src=s,m.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(s)try{const a=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:s})});if(a.ok){const e=await a.json();c.href=e.path+"?t="+Date.now(),v.src=e.path+"?t="+Date.now(),m.textContent="Using custom favicon",s=null}else{const e=await a.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(a){showNotification("Error saving favicon: "+a.message,"error")}}),h(),y();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{b.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const t=await n.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(n){console.warn("Failed to save timezone:",n.message)}}))})(),window.populateTimezoneSelect=function(b,E){const N=Intl.supportedValuesOf("timeZone"),S=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const T of N){const P=document.createElement("option");P.value=T,P.textContent=T.replace(/_/g," "),T===S&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",E=null;async function N(){try{const z=await fetch("/api/v1/config");if(z.ok&&(E=await z.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(z){console.warn("Could not fetch server config, checking localStorage fallback:",z.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}N();const S=document.getElementById("setup-timezone");S&&window.populateTimezoneSelect(S);function T(w){document.querySelectorAll(".setup-step").forEach(f=>{f.style.display="none"});const z=document.getElementById(w);z&&(z.style.display="block")}function P(){const w=document.getElementById("setup-summary-content");if(!w)return;let z='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const p=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",v=document.getElementById("setup-dns-ip")?.value?.trim()||"",m=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;z+=`
|
|
<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> ${p}</div>
|
|
<div><strong>Certificate Authority:</strong> ${y}</div>
|
|
<div><strong>DNS Server:</strong> ${v}:${m}</div>
|
|
<div><strong>Example URLs:</strong> https://uptime${p}, https://nextcloud${p}</div>
|
|
</div>
|
|
</div>
|
|
`}else if(b==="simple"){const p=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";z+=`
|
|
<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> ${p}</div>
|
|
<div><strong>SSL:</strong> None (HTTP only)</div>
|
|
<div><strong>Example URLs:</strong> http://${p}:8080, http://${p}:3000</div>
|
|
</div>
|
|
</div>
|
|
`}else if(b==="public"){const p=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",v=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m=v==="subdirectory"?`https://${p}/sonarr, https://${p}/grafana`:`https://sonarr.${p}, https://grafana.${p}`;z+=`
|
|
<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> ${p}</div>
|
|
<div><strong>SSL:</strong> Let's Encrypt</div>
|
|
<div><strong>Email:</strong> ${y}</div>
|
|
<div><strong>Routing:</strong> ${v==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
<div><strong>Example URLs:</strong> ${m}</div>
|
|
</div>
|
|
</div>
|
|
`}const f=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";z+=`
|
|
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${f.replace(/_/g," ")}</div>
|
|
</div>
|
|
`,z+="</div>",w.innerHTML=z,T("setup-step-summary")}async function L(w){try{const z=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(w)});return z.ok?(await z.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${z.status}`),{function:"saveConfigToServer"}),!1)}catch(z){return errorHandler.logError("[SetupWizard] Save Config",z,{function:"saveConfigToServer"}),!1}}async function H(){const w={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};b==="homelab"?(w.tld=document.getElementById("setup-tld")?.value?.trim()||".home",w.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",w.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()||""},w.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):b==="simple"?(w.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",w.defaults={dnsType:"none",sslType:"none",targetIP:w.defaultIP}):b==="public"&&(w.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",w.email=document.getElementById("setup-public-email")?.value?.trim()||"",w.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",w.defaults={dnsType:w.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const z=await L(w);safeSet("dashcaddy-config",JSON.stringify(w)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const f=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",p=z?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${f}. Settings saved to: ${p}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(w){w.preventDefault();const z=document.querySelector('input[name="config-type"]:checked');z&&(b=z.value),T(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const I=document.getElementById("setup-skip");I&&(I.onclick=async function(w){w.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await L({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(w){const z=w.target.value||".home",f=document.getElementById("tld-preview"),p=document.getElementById("tld-preview-2");f&&(f.textContent=z),p&&(p.textContent=z)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(w){w.preventDefault(),T("setup-step-1")});const $=document.getElementById("setup-homelab-next");$&&($.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-tld")?.value?.trim()||"",f=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!z||!z.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!f){showNotification("Please enter a Certificate Authority name","warning");return}if(!p){showNotification("Please enter your DNS server IP address","warning");return}P()});const C=document.getElementById("setup-simple-back");C&&(C.onclick=function(w){w.preventDefault(),T("setup-step-1")});const R=document.getElementById("setup-simple-next");R&&(R.onclick=function(w){w.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(w){w.onchange=function(){var z=document.getElementById("dns-requirement-note");z&&(z.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const M=document.getElementById("setup-public-back");M&&(M.onclick=function(w){w.preventDefault(),T("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"";if(!z){showNotification("Please enter your domain name","warning");return}if(!f||!f.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const B=document.getElementById("setup-summary-back");B&&(B.onclick=function(w){w.preventDefault(),b==="homelab"?T("setup-step-homelab"):b==="simple"?T("setup-step-simple"):b==="public"&&T("setup-step-public")});const A=document.getElementById("setup-finish");A&&(A.onclick=function(w){w.preventDefault(),H()}),window.getGlobalConfig=async function(){try{const z=await fetch("/api/v1/config");if(z.ok){const f=await z.json();if(f&&f.setupComplete)return f}}catch{console.warn("Could not fetch config from server")}const w=safeGet("dashcaddy-config");return w?JSON.parse(w):{setupComplete:!1,configurationType:"homelab",tld:".home",caName:"",defaults:{dnsType:"private",sslType:"internal",targetIP:"localhost"}}},window.resetSetupWizard=async function(){if(confirm("Reset DashCaddy configuration? This will show the setup wizard again.")){try{await secureFetch("/api/v1/config",{method:"DELETE"})}catch{console.warn("Could not delete server config")}safeRemove("dashcaddy-setup"),safeRemove("dashcaddy-config"),location.reload()}}})(),(function(){const b=new ErrorHandler;injectModal("app-selector-modal",`<div id="app-selector-modal" class="weather-modal">
|
|
<div class="app-selector-content">
|
|
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
|
<div id="app-selector-grid" class="app-selector-grid"></div>
|
|
<div style="text-align: center; margin-top: 24px;">
|
|
<button id="app-selector-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>`),injectModal("app-deploy-modal",`<div id="app-deploy-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
|
|
<h3 id="app-deploy-title">Deploy Application</h3>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 16px;">
|
|
<!-- Subdomain/Domain -->
|
|
<div>
|
|
<label for="deploy-subdomain" class="form-label-accent-sm">
|
|
\u{1F310} Subdomain or Domain
|
|
</label>
|
|
<input type="text" id="deploy-subdomain" placeholder="uptime"
|
|
style="width: 100%; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.95rem;" />
|
|
<div class="form-hint-sm">
|
|
Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.home</span>
|
|
</div>
|
|
<div id="subpath-compat-warning" style="display: none; padding: 8px 12px; border-radius: 6px; background: color-mix(in srgb, #ff9800 10%, var(--card-bg)); border: 1px solid color-mix(in srgb, #ff9800 30%, transparent); margin-top: 8px; font-size: 0.85rem;"></div>
|
|
</div>
|
|
|
|
<!-- DNS Configuration -->
|
|
<div>
|
|
<label class="form-label-accent">
|
|
\u{1F5C2}\uFE0F DNS Configuration
|
|
</label>
|
|
<div class="flex-col-gap">
|
|
<label class="radio-option">
|
|
<input type="radio" name="dns-type" value="private" checked style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Private DNS (Technitium)</div>
|
|
<div class="text-hint">Use your local DNS server with custom TLD (.sami, .home, etc.)</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="dns-type" value="public" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Public DNS</div>
|
|
<div class="text-hint">Use a real domain (example.com) - requires DNS provider setup</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="dns-type" value="none" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">No DNS (IP:Port only)</div>
|
|
<div class="text-hint">Access via IP address and port - no domain setup</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SSL Configuration -->
|
|
<div>
|
|
<label class="form-label-accent">
|
|
\u{1F512} SSL/TLS Certificate
|
|
</label>
|
|
<div class="flex-col-gap">
|
|
<label class="radio-option">
|
|
<input type="radio" name="ssl-type" value="internal" checked style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Internal CA</div>
|
|
<div class="text-hint">Use Caddy's internal certificate authority (self-signed)</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="ssl-type" value="letsencrypt" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">Let's Encrypt</div>
|
|
<div class="text-hint">Free public SSL certificate (requires public domain)</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" name="ssl-type" value="none" style="margin-right: 10px;" />
|
|
<div>
|
|
<div class="fw-500">No SSL (HTTP only)</div>
|
|
<div class="text-hint">\u26A0\uFE0F Not recommended - traffic will be unencrypted</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Media Library Path (shown for media apps) -->
|
|
<div id="media-path-section" style="display: none;">
|
|
<label class="form-label-accent">
|
|
\u{1F4C1} Media Library Location
|
|
</label>
|
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
|
<!-- Detected mounts from existing media servers -->
|
|
<div id="detected-mounts-container" style="display: none;">
|
|
<div style="font-size: 0.85rem; color: var(--success); margin-bottom: 6px;">
|
|
\u2713 Detected from existing media servers:
|
|
</div>
|
|
<div id="detected-mounts-list" style="display: flex; gap: 8px; flex-wrap: wrap;"></div>
|
|
</div>
|
|
|
|
<!-- Path input with browse button -->
|
|
<div class="flex-row-gap">
|
|
<input type="text" id="deploy-media-path" placeholder="/media/Movies, /media/TVShows"
|
|
class="input-flex" style="font-size: 0.95rem;" />
|
|
<button type="button" id="browse-media-btn" style="padding: 10px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap;">
|
|
\u{1F4C2} Browse
|
|
</button>
|
|
</div>
|
|
|
|
<div id="media-path-description" class="text-hint">
|
|
Select folders from existing servers, browse, or type paths manually. Separate multiple with commas.
|
|
</div>
|
|
|
|
<!-- Selected paths display -->
|
|
<div id="selected-media-paths" style="display: none; flex-wrap: wrap; gap: 6px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plex Claim Token (shown only for Plex deployments) -->
|
|
<div id="plex-claim-section" style="display: none;">
|
|
<label class="form-label-accent">
|
|
\u{1F511} Plex Claim Token
|
|
</label>
|
|
<div class="flex-row-gap">
|
|
<input type="text" id="deploy-plex-claim" placeholder="claim-xxxxxxxxxxxxxxxxxxxx"
|
|
class="input-flex" style="font-size: 0.95rem;" />
|
|
<a href="https://plex.tv/claim" target="_blank" rel="noopener noreferrer"
|
|
style="padding: 10px 16px; background: #e5a00d; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap; text-decoration: none; display: flex; align-items: center;">
|
|
Get Token
|
|
</a>
|
|
</div>
|
|
<div style="font-size: 0.8rem; color: #e5a00d; margin-top: 4px;">
|
|
Token expires in 4 minutes! Get it right before clicking Deploy. Leave empty to configure Plex manually later.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tailscale Security -->
|
|
<div id="tailscale-section">
|
|
<label class="form-label-accent">
|
|
\u{1F510} Tailscale Security
|
|
</label>
|
|
<div class="flex-col-gap">
|
|
<label class="radio-option">
|
|
<input type="checkbox" id="deploy-tailscale-only" style="margin-right: 10px; width: 18px; height: 18px;" />
|
|
<div>
|
|
<div class="fw-500">Tailscale-Only Access</div>
|
|
<div class="text-hint">Restrict this service to Tailscale users only (100.x.x.x IPs)</div>
|
|
</div>
|
|
</label>
|
|
<div id="tailscale-status" style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
<span style="color: var(--muted);">Checking Tailscale status...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Options (Collapsible) -->
|
|
<details>
|
|
<summary style="cursor: pointer; color: var(--accent); font-weight: 500; margin-bottom: 8px;">\u2699\uFE0F Advanced Options</summary>
|
|
<div style="margin-top: 12px; display: grid; gap: 12px;">
|
|
<div>
|
|
<label for="deploy-port" style="display: block; margin-bottom: 6px;">Custom Port (optional)</label>
|
|
<input type="number" id="deploy-port" placeholder="Leave empty for default"
|
|
class="form-input-card" />
|
|
</div>
|
|
<div>
|
|
<label for="deploy-ip" style="display: block; margin-bottom: 6px;">Target IP Address</label>
|
|
<input type="text" id="deploy-ip" value="localhost"
|
|
class="form-input-card" />
|
|
<div class="form-hint-sm">
|
|
Use 'localhost' for same-host containers, or specific IP for remote services
|
|
</div>
|
|
</div>
|
|
<div id="volume-mounts-section" style="display: none;">
|
|
<label class="form-label-accent-sm">\u{1F4C2} Volume Mounts</label>
|
|
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
|
Customize where container data is stored on the host. Media volumes are configured above.
|
|
</div>
|
|
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
|
|
</div>
|
|
<div style="margin-top: 12px;">
|
|
<label class="form-label-accent-sm">\u2699\uFE0F Resource Limits</label>
|
|
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
|
Optional CPU and memory constraints. Leave at 0 for unlimited.
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
|
<div>
|
|
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
|
|
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
|
</div>
|
|
<div>
|
|
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
|
|
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
|
<button id="app-deploy-cancel">Cancel</button>
|
|
<button id="app-deploy-confirm" class="btn-accent">
|
|
\u{1F680} Deploy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const E="custom-apps";let N=null,S=null;const T=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function L(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return N=c.templates,S=c.categories,!0}catch(r){b.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function H(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function g(r){try{const s=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(s.success)return s.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function I(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!N&&!await L()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[s,h]of Object.entries(N)){const u=h.category||"Other";r[u]||(r[u]=[]),r[u].push({id:s,...h})}const c=S?Object.keys(S):Object.keys(r).sort();for(const s of c){const h=r[s];if(!h||h.length===0)continue;h.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const a=S?.[s]||{};u.innerHTML=`${escapeHtml(a.icon||"")} ${escapeHtml(s)}`,a.color&&(u.style.borderBottomColor=a.color),P.appendChild(u),h.forEach(e=>{const n=document.createElement("div");n.className="app-option";const t=e.isDashboardWidget,i=t&&safeGet("widget-"+e.id+"-enabled")!=="false",o=t?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",d=!t&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
|
|
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
|
|
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
|
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
|
${o}${d}
|
|
`,t?n.onclick=()=>k(e,n):n.onclick=()=>x(e),P.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function k(r,c){const s="widget-"+r.id+"-enabled",u=!(safeGet(s)!=="false");safeSet(s,String(u));const a=r.widgetSelector;if(a){const n=document.querySelector(a);n&&(n.style.display=u?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=u?"ON":"OFF",e.style.background=u?"#2ecc7130":"#e74c3c30",e.style.color=u?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function x(r){const c=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),h=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),a=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),t=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:r.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${r.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.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}s.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");h.value=i;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=r.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">ⓘ '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const d=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),D=document.querySelector(`input[name="dns-type"][value="${d}"]`),O=document.querySelector(`input[name="ssl-type"][value="${l}"]`);D?D.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,O?O.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,a.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,J=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),J&&!J.dataset.moved&&(U.appendChild(J),J.dataset.moved="1")}const G=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){G.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const _=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){_.style.display="block",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=W.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),W.value=le.join(", ")},J.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else G.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const _=r.mediaMount?.containerPath,J=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));J.length>0?(ne.style.display="block",J.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
|
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
|
|
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
|
|
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
|
|
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
|
|
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const J=await H(_);if(J.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(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,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const J=await(await fetch("/api/v1/tailscale/status")).json();J.success&&J.installed&&J.connected?t.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?t.innerHTML='<span style="color: #ff9800;">Not connected</span>':(t.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{t.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=h.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}/${_}`;else if(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${a.value}:${te}`}u.textContent=K}h.oninput=ae,a.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),T.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function $(r){const c=r.appTemplate,s=safeGetJSON(E,[]),h=c._useExisting&&c._existingContainer,u=s.find(a=>a.id===r.subdomain);if(!(u&&!h&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(u){const a=s.indexOf(u);s.splice(a,1),safeSet(E,JSON.stringify(s))}if(h)r.port=c._existingContainer.primaryPort;else{const a=r.port||c.defaultPort||8080;showNotification(`Checking port ${a} availability...`,"info",0);const e=await H(a);if(!e.available){const n=await g(c.defaultPort||8080);if(confirm(`Port ${a} is already in use by ${e.conflict?.usedBy||"another container"}.
|
|
|
|
Would you like to use port ${n} instead?`))r.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(h?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const a={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};h&&(a.config.useExisting=!0,a.config.existingContainerId=c._existingContainer.id,a.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(a.config.port=c._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();if(n.success){const t={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:n.containerId,url:n.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};s.push(t),safeSet(E,JSON.stringify(s)),window.APPS&&!window.APPS.some(o=>o.id===c.id)&&(window.APPS.push(t),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=n.usedExisting?`${c.name} configured with existing container!
|
|
URL: ${n.url}`:`${c.name} deployed successfully!
|
|
URL: ${n.url}`;n.warning&&(i+=`
|
|
|
|
\u26A0 Warning: ${n.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,n.url&&n.url.startsWith("https://")&&C(n.url,c.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
|
|
`);showNotification(`Setup Instructions for ${c.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(a){b.logError("[AppSelector] Deployment",a,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${a.message}`,"error",8e3)}}}async function C(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let s=0;const h=12,u=async()=>{s++;try{const a=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<h?setTimeout(u,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
|
|
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(u,3e3)}function R(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(s=>s.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{I(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const M=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(M.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{s.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const h={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:s.length>0?s:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!h.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}M.classList.remove("show"),$(h)}),wireModal(M);const j=document.getElementById("folder-browser-modal"),B=document.getElementById("folder-browser-path"),A=document.getElementById("folder-browser-list"),w=document.getElementById("folder-browser-selected"),z=document.getElementById("folder-browser-selected-list");let f="",p=[],y=null;window.openFolderBrowser=function(r){y=r,p=r.value.split(",").map(c=>c.trim()).filter(c=>c),f="",m(),v(""),j.classList.add("show")};async function v(r){B.textContent=r||"Select a drive...",A.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const s=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!s.success){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}f=s.path||"",B.textContent=f||"Select a drive...";let h="";s.parent&&s.parent!==s.path&&(h+=`<div class="folder-item" data-path="${escapeHtml(s.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>`),s.items.length===0&&!s.parent?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':s.items.length===0?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(u=>{const a=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=p.includes(u.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";h+=`<div class="folder-item" data-path="${escapeHtml(u.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
|
|
<span style="font-size: 1.2rem;">${a}</span>
|
|
<span style="flex: 1;">${escapeHtml(u.name)}</span>
|
|
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
|
</div>`}),A.innerHTML=h,A.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{v(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const a=p.includes(u.dataset.path);u.style.background=a?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function m(){if(p.length===0){w.style.display="none";return}w.style.display="block",z.innerHTML=p.map(r=>`
|
|
<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(r)}
|
|
<button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
|
</span>
|
|
`).join("")}window.removeSelectedFolder=function(r){p=p.filter(c=>c!==r),m(),v(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!p.includes(f)&&(p.push(f),m(),v(f))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{y&&(y.value=p.join(", ")),j.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 b=null,E=null,N=null,S=1,T=!1;const P=document.getElementById("recipe-deploy-modal"),L=document.getElementById("recipe-cancel"),H=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(P,L);async function I(){try{const f=await fetch("/api/v1/recipes/templates"),p=await f.json();if(p.success)return b=p.templates,E=p.categories,!0;if(f.status===403)return T=!1,!1}catch(f){console.warn("Failed to fetch recipe templates:",f.message)}return!1}async function k(){try{T=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{T=!1}return T}window.renderRecipeCards=async function(f){await k();let p;if(T&&b?p=b:p=x(),!p||p.length===0)return;const y=document.createElement("div");y.className="app-category-header",y.innerHTML="\u{1F9EA} Recipes",y.style.borderBottomColor="#8e44ad",f.appendChild(y);const v=Array.isArray(p)?p:Object.values(p);v.sort((m,r)=>(r.popularity||0)-(m.popularity||0));for(const m of v){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${m.componentCount||m.components?.length||"?"} apps</div>`,s=T?"":'<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>';r.innerHTML=`
|
|
${s}
|
|
<div class="app-option-icon">${escapeHtml(m.icon||"\u{1F9EA}")}</div>
|
|
<div class="app-option-name">${escapeHtml(m.name)}</div>
|
|
<div class="app-option-desc">${escapeHtml(m.description||"")}</div>
|
|
${c}
|
|
`,r.onclick=()=>{if(!T){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}$(m)},f.appendChild(r)}};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 $(f){N=f,S=1;const p=document.getElementById("app-selector-modal");p&&p.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${f.name}`,C(),R(),P.classList.add("show")}function C(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(f=>{const p=parseInt(f.dataset.step);f.classList.toggle("active",p===S),f.classList.toggle("completed",p<S)});for(let f=1;f<=4;f++){const p=document.getElementById(`recipe-step-${f}`);p&&(p.style.display=f===S?"":"none")}H.style.display=S>1&&S<4?"":"none",S===4?(g.style.display="none",L.textContent="Close"):S===3?(g.textContent="\u{1F680} Deploy",g.style.display="",L.textContent="Cancel"):(g.textContent="Next",g.style.display="",L.textContent="Cancel")}function R(){const f=document.getElementById("recipe-component-list");f.innerHTML="";const p=N.components||[];for(const y of p){const v=document.createElement("div");v.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const m=y.required,r=y.internal;v.innerHTML=`
|
|
<input type="checkbox" ${m?"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"}
|
|
${m?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
${r?'<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>
|
|
`,f.appendChild(v)}}function M(){const f=document.getElementById("recipe-volumes-section"),p=document.getElementById("recipe-volume-list"),y=N.sharedVolumes;if(y&&Object.keys(y).length>0){f.style.display="",p.innerHTML="";for(const[v,m]of Object.entries(y)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(m.label||v)}</label>
|
|
<input type="text" data-volume-key="${v}" value="${escapeHtml(m.defaultPath||"")}"
|
|
placeholder="${escapeHtml(m.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(m.description||"")}</div>
|
|
`,p.appendChild(r)}}else f.style.display="none"}function j(){const f=document.getElementById("recipe-review-content"),p=B(),y=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};y.forEach(s=>{v[s.dataset.volumeKey]=s.value});const m=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;f.innerHTML=`
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(N.name)}</div>
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(N.description||"")}</div>
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
<strong>Components (${p.length}):</strong>
|
|
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
|
${p.map(s=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
\u2022 <strong>${escapeHtml(s.role||s.id)}</strong> ${s.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
|
</div>`).join("")}
|
|
</div>
|
|
</div>
|
|
|
|
${Object.keys(v).length>0?`<div style="margin-bottom: 12px;">
|
|
<strong>Volumes:</strong>
|
|
${Object.entries(v).map(([s,h])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${s}: ${escapeHtml(h)}</div>`).join("")}
|
|
</div>`:""}
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted);">
|
|
Timezone: ${escapeHtml(m)} • IP: ${escapeHtml(r)} ${c?"• Tailscale only":""}
|
|
</div>
|
|
|
|
${N.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(N.network.name)}</code></div>`:""}
|
|
`}function B(){const f=document.querySelectorAll("#recipe-component-list input[data-component-id]"),p=new Set;f.forEach(v=>{v.checked&&p.add(v.dataset.componentId)});const y=N.components||[];return y.filter(v=>v.required).forEach(v=>p.add(v.id)),y.filter(v=>p.has(v.id))}async function A(){const f=document.getElementById("recipe-progress-list"),p=document.getElementById("recipe-deploy-result");p.style.display="none",f.innerHTML="";const y=B();for(const c of y){const s=document.createElement("div");s.id=`recipe-progress-${c.id}`,s.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;",s.innerHTML=`
|
|
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
|
|
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
|
`,f.appendChild(s)}const v=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};v.forEach(c=>{m[c.dataset.volumeKey]=c.value});const r={selectedComponents:y.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:m},componentOverrides:{}};for(const c of y)w(c.id,"deploying","Deploying...");try{const s=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:N.id,config:r})})).json();if(s.success){for(const h of s.deployed||[])w(h.id,"success",h.url?`Running \u2192 ${h.url}`:"Running");for(const h of s.errors||[])w(h.componentId,"error",h.error);p.style.display="",p.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(s.message||"Deployed!")}</div>
|
|
${s.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;">${s.setupInstructions.map(h=>`<li>${escapeHtml(h)}</li>`).join("")}</ul>
|
|
</div>`:""}
|
|
</div>
|
|
`,showNotification(`${N.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else p.style.display="",p.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(s.error||"Unknown error")}
|
|
</div>`,showNotification(`Recipe deployment failed: ${s.error}`,"error",5e3)}catch(c){p.style.display="",p.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
<strong>Network error:</strong> ${escapeHtml(c.message)}
|
|
</div>`}}function w(f,p,y){const v=document.getElementById(`recipe-progress-${f}`);if(!v)return;const m=v.querySelector(".recipe-progress-icon"),r=v.querySelector(".recipe-progress-status");p==="deploying"?(m.textContent="\u23F3",r.style.color="var(--accent)"):p==="success"?(m.textContent="\u2705",r.style.color="var(--ok-fg)"):p==="error"&&(m.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=y}g.addEventListener("click",()=>{if(S===3){S=4,C(),A();return}S<3&&(S++,C(),S===2&&M(),S===3&&j())}),H.addEventListener("click",()=>{S>1&&S<4&&(S--,C())}),window.groupRecipeCards=function(){const f=document.querySelectorAll(".service-card[data-recipe-id]");if(f.length===0)return;const p={};f.forEach(y=>{const v=y.dataset.recipeId;p[v]||(p[v]=[]),p[v].push(y)});for(const[y,v]of Object.entries(p))v.length<2||v.forEach((m,r)=>{if(m.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=m.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=y.replace(/-/g," "),m.style.position="relative",m.appendChild(c))}})},window.manageRecipe=async function(f,p){const y=`/api/v1/recipes/${f}/${p}`,v=p==="remove"?"DELETE":"POST",m=p==="remove"?`/api/v1/recipes/${f}`:y;if(!(p==="remove"&&!confirm(`Remove the entire ${f} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(m,{method:v})).json();c.success?(showNotification(`Recipe ${p}: ${c.results?.filter(s=>s.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${p} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const z=document.createElement("style");z.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(z),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),E=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const N=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),S=await N.json();if(N.ok&&S.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3);else throw new Error(S.error||"Reload failed")}catch(N){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${N.message}`,"error"),setTimeout(()=>{b.textContent=E,b.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 b=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),N=document.getElementById("view-error-logs"),S=document.getElementById("error-log-refresh"),T=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function L(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const I=await(await fetch("/api/v1/error-logs")).json();I.success&&I.logs?I.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=I.logs.map(k=>`
|
|
<div class="log-entry error">
|
|
<span class="log-timestamp">${new Date(k.timestamp).toLocaleString()}</span>
|
|
<span class="log-level">ERROR</span>
|
|
<div class="log-message">
|
|
<strong>${escapeHtml(k.context)}</strong>: ${escapeHtml(k.error)}
|
|
${k.details?`<br><small style="opacity: 0.7;">${escapeHtml(k.details)}</small>`:""}
|
|
</div>
|
|
</div>
|
|
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function H(){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),L()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}N?.addEventListener("click",()=>{b.classList.add("show"),L()}),S?.addEventListener("click",L),T?.addEventListener("click",H),wireModal(b,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
|
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
|
|
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
|
|
<div>
|
|
<h3 style="margin: 0;">\u{1F4DC} Container Logs</h3>
|
|
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">View and stream Docker container logs</p>
|
|
</div>
|
|
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center;">
|
|
<select id="cl-container-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 200px;">
|
|
<option value="">Select a container...</option>
|
|
</select>
|
|
<input type="text" id="cl-log-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 150px;" />
|
|
<select id="cl-log-tail" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
|
|
<option value="50">Last 50 lines</option>
|
|
<option value="100" selected>Last 100 lines</option>
|
|
<option value="500">Last 500 lines</option>
|
|
<option value="1000">Last 1000 lines</option>
|
|
<option value="all">All logs</option>
|
|
</select>
|
|
<button id="cl-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u{1F504} Refresh</button>
|
|
<button id="cl-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u25B6 Stream</button>
|
|
<button id="cl-download" style="padding: 6px 14px; font-size: 0.85rem;">\u{1F4BE} Download</button>
|
|
<button id="cl-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">\u2715</button>
|
|
<button id="cl-close" class="close-btn" style="padding: 6px 10px;">\u2715</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="cl-container-info" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem;">
|
|
<span><strong>Image:</strong> <span id="cl-image">-</span></span>
|
|
<span><strong>Status:</strong> <span id="cl-status">-</span></span>
|
|
<span><strong>Created:</strong> <span id="cl-created">-</span></span>
|
|
</div>
|
|
|
|
<div id="cl-log-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
|
|
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>
|
|
</div>
|
|
|
|
<div id="cl-stream-status" style="display: none; padding: 8px 12px; background: var(--ok-bg, #1a3a1a); color: var(--ok-fg, #4ade80); border-radius: 6px; margin-top: 8px; font-size: 0.82rem;">
|
|
<span id="cl-stream-indicator">\u{1F534}</span> <span id="cl-stream-text">Disconnected</span>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
|
|
<span id="cl-line-count">0 lines</span>
|
|
<span>|</span>
|
|
<span id="cl-filter-count">0 filtered</span>
|
|
</div>
|
|
<button id="cl-close-btn" class="btn-secondary">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const b=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),N=document.getElementById("cl-log-content"),S=document.getElementById("cl-log-search"),T=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),L=document.getElementById("cl-stream"),H=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),I=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),x=document.getElementById("cl-stream-status"),$=document.getElementById("cl-stream-indicator"),C=document.getElementById("cl-stream-text"),R=document.getElementById("cl-line-count"),M=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),B=document.getElementById("cl-status"),A=document.getElementById("cl-created");let w=null,z=[],f=[],p=null,y=!1,v=null;function m(d){if(!d)return"-";const l=new Date(d);return isNaN(l.getTime())?d:l.toLocaleString()}function r(d){if(!d)return"";const l=document.createElement("div");return l.textContent=d,l.innerHTML}function c(d,l){const D=d.stream==="stderr"?"log-stderr":"log-stdout",O=d.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
<div class="log-entry ${D}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
|
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
|
|
<span class="log-stream-icon" style="color: ${d.stream==="stderr"?"#f59e0b":"#22c55e"};">${O}</span>
|
|
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(d.text)}</span>
|
|
</div>
|
|
`}function s(d,l=""){if(!d||d.length===0){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',R.textContent="0 lines",M.textContent="0 filtered";return}if(z=d,f=l?d.filter(D=>D.text&&D.text.toLowerCase().includes(l.toLowerCase())):d,R.textContent=`${d.length} lines`,M.textContent=l?`${f.length} of ${d.length} shown`:`${d.length} shown`,f.length===0){N.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}N.innerHTML=f.map((D,O)=>c(D,O)).join(""),N.scrollTop=N.scrollHeight}async function h(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],D=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(O=>{const F=document.createElement("option");F.value=O.id,F.textContent=`${O.name} (${O.image.split(":")[0]}) - ${O.status}`,F.dataset.name=O.name,F.dataset.image=O.image,F.dataset.status=O.status,F.dataset.created=O.created,E.appendChild(F)}),D&&E.querySelector(`option[value="${D}"]`)&&(E.value=D,u(D))}catch(d){console.error("Failed to load containers:",d)}}function u(d){const l=E.querySelector(`option[value="${d}"]`);l&&(j.textContent=l.dataset.image||"-",B.textContent=l.dataset.status||"-",B.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",A.textContent=m(l.dataset.created))}async function a(){const d=E.value;if(!d){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),w=d,u(d);const l=T.value,D=S.value.trim();N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const O=`/api/v1/logs/container/${d}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(O);F.logs&&F.logs.length>0?s(F.logs,D):(N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',R.textContent="0 lines",M.textContent="0 filtered")}catch(O){N.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(O.message)}</div>`}}function e(){const d=E.value;if(!d)return;n(),y=!0,L.textContent="\u23F9 Stop",x.style.display="flex",$.textContent="\u{1F7E2}",C.textContent="Connecting...";const l=`/api/v1/logs/stream/${d}`;p=new EventSource(l),p.onopen=()=>{$.textContent="\u{1F7E2}",C.textContent="Connected - streaming logs"},p.onmessage=D=>{try{const O=JSON.parse(D.data);if(O.error){$.textContent="\u{1F534}",C.textContent=`Error: ${O.error}`;return}z.push(O),f.push(O),R.textContent=`${z.length} lines`,M.textContent=`${f.length} shown`;const F=S.value.trim();if(!F||O.text&&O.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(O,f.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",N.appendChild(U),N.scrollTop=N.scrollHeight}}catch(O){console.error("Error parsing log:",O)}},p.onerror=()=>{$.textContent="\u{1F534}",C.textContent="Disconnected",y=!1,L.textContent="\u25B6 Stream"},b._eventSource=p}function n(){p&&(p.close(),p=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),y=!1,L.textContent="\u25B6 Stream",x.style.display="none"}function t(){if(!z||z.length===0){showNotification("No logs to download","error");return}const d=E.querySelector(`option[value="${w}"]`)?.dataset.name||w,l=new Date().toISOString().replace(/[:.]/g,"-"),D=`${d}-logs-${l}.txt`,O=z.map(G=>{const W=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${W?W+" ":""}${X} ${G.text}`}).join(`
|
|
`),F=new Blob([O],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=D,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${z.length} log lines`,"success")}E?.addEventListener("change",()=>{a()}),T?.addEventListener("change",()=>{a()}),P?.addEventListener("click",()=>{a()}),L?.addEventListener("click",()=>{y?n():e()}),H?.addEventListener("click",()=>{t()}),g?.addEventListener("click",()=>{S.value="",s(z,"")}),S?.addEventListener("input",()=>{clearTimeout(v),v=setTimeout(()=>{s(z,S.value.trim())},300)}),S?.addEventListener("keydown",d=>{d.key==="Escape"&&(S.value="",s(z,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),h()});function o(){n(),b.classList.remove("show")}I?.addEventListener("click",o),k?.addEventListener("click",o),document.addEventListener("keydown",d=>{d.key==="Escape"&&b.classList.contains("show")&&o()}),wireModal(b,null,o),window.openContainerLogsModal=function(d,l){b.classList.add("show"),h().then(()=>{const D=Array.from(E.options).find(O=>O.value===d||O.dataset.name===l);D?(E.value=D.value,u(D.value),a()):d?(w=d,j.textContent=l||d,B.textContent="-",A.textContent="-",a()):N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
|
|
<h3>\u{1F4BE} Container Snapshots</h3>
|
|
<p class="modal-subtitle">
|
|
Create and manage Docker container checkpoints for instant state recovery.
|
|
</p>
|
|
|
|
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
|
|
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
|
|
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
|
<option value="">-- Select a container --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
|
|
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
|
|
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
|
|
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
|
|
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-tabs" style="margin-bottom: 12px;">
|
|
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
|
|
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
|
|
</div>
|
|
|
|
<div id="snapshot-create" class="panel-section active">
|
|
<div style="margin-bottom: 12px;">
|
|
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
|
|
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
|
|
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
|
|
</div>
|
|
<div style="margin-bottom: 12px;">
|
|
<label class="checkbox-label" style="font-size: 0.85rem;">
|
|
<input type="checkbox" id="snapshot-leave-running" checked />
|
|
Leave container running after checkpoint (resume without restart)
|
|
</label>
|
|
</div>
|
|
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">\u{1F4BE} Create Snapshot</button>
|
|
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
|
|
</div>
|
|
|
|
<div id="snapshot-list" class="panel-section" style="display: none;">
|
|
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
|
|
<div class="panel-empty"><span class="empty-icon">\u{1F4BE}</span>Select a container to view its snapshots</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="snapshot-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const b=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),N=document.getElementById("snapshot-close"),S=document.getElementById("snapshot-container-select"),T=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),L=document.getElementById("snapshot-create-status");let H=null;async function g(){try{const R=await(await fetch("/api/v1/containers")).json();if(!R.success||!R.containers)return;S.innerHTML='<option value="">-- Select a container --</option>';for(const M of R.containers){const j=document.createElement("option");j.value=M.id,j.textContent=`${M.name||M.id} (${M.image||"unknown"})`,j.dataset.name=M.name,j.dataset.image=M.image,j.dataset.status=M.status,j.dataset.created=M.created,S.appendChild(j)}}catch(C){console.error("Failed to load containers:",C)}}function I(C){if(!C||!C.value){T.style.display="none",H=null;return}H=C.value,document.getElementById("snapshot-image").textContent=C.dataset.image||"-",document.getElementById("snapshot-status").textContent=C.dataset.status||"-",document.getElementById("snapshot-created").textContent=C.dataset.created?new Date(C.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=C.value.substring(0,12),T.style.display=""}async function k(){if(!H){L.textContent="Please select a container first",L.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-name").value.trim();if(!C){L.textContent="Please enter a snapshot name",L.style.color="var(--bad-fg)";return}const R=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",L.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(H)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:C,leaveRunning:R})})).json();j.success?(L.textContent=`\u2713 Snapshot "${C}" created successfully`,L.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(L.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,L.style.color="var(--bad-fg)")}catch(M){L.textContent=`\u2717 Error: ${M.message}`,L.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function x(){b.classList.add("show"),g()}function $(){b.classList.remove("show"),T.style.display="none",H=null,S.selectedIndex=0}E?.addEventListener("click",x),N?.addEventListener("click",$),wireModal(b,N),S?.addEventListener("change",C=>{const R=S.options[S.selectedIndex];I(R)}),P?.addEventListener("click",k),b?.querySelectorAll(".panel-tab").forEach(C=>{C.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(R=>R.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(R=>R.classList.remove("active")),C.classList.add("active"),b.querySelector(`#${C.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
|
|
<h3>\u{1F3AC} Smart Arr Connect</h3>
|
|
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
|
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 b=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),N=document.getElementById("arr-setup-cancel"),S=document.getElementById("smart-connect-btn"),T=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),L=document.getElementById("smart-phase-progress"),H=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),I=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),$=document.getElementById("smart-plex-libraries"),C=document.getElementById("smart-retry-btn");let R=null;const M={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function B(v){T.style.display=v==="detect"?"block":"none",P.style.display=v==="credentials"?"block":"none",L.style.display=v==="progress"?"block":"none",H.style.display=v==="results"?"block":"none"}function A(v){const m={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"}},r=m[v]||m.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, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function w(){B("detect"),g.style.display="none";try{if(R=await(await fetch("/api/v1/arr/smart-detect")).json(),!R.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(R.error)}</div>`,g.style.display="block";return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,s]of Object.entries(R.services)){const h=M[c]||"\u{1F4E6}",u=j[c]||c,a=s.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(s.source)}</span>`:"",e=s.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(s.version)}</span>`:"",n=(s.hasApiKey||s.hasToken)&&s.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";m+=`<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;">${h}</span>
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
|
|
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
|
${a} ${e} ${n}
|
|
</div>
|
|
</div>
|
|
${A(s.status)}
|
|
</div>`}m+="</div>";const r=R.summary;m+=`<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(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected ·
|
|
${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` · <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
|
|
</div>`,g.innerHTML=m,g.style.display="block",z(R),setTimeout(()=>{B("credentials")},800)}catch(v){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(v.message)}</div>`,g.style.display="block"}}function z(v){let m="";const r=v.services,c=["radarr","sonarr","prowlarr"];for(const u of c){const a=r[u];if(!a||a.status==="not_found"&&!a.url)continue;const e=M[u],n=j[u],t=a.status==="connected";m+=`<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;">${n}</span>
|
|
<span id="smart-${u}-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-${u}-url" value="${escapeHtml(a.url||"")}" placeholder="https://seedbox.com/${u}/"
|
|
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
|
</div>
|
|
<div>
|
|
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
|
<input type="password" id="smart-${u}-key" placeholder="${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('${u}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
</div>`}const s=r.plex;if(s){const u=s.status==="connected";m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
|
|
<span style="font-weight: 500;">Plex</span>
|
|
${A(s.status)}
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(s.source||"")}</span>
|
|
</div>
|
|
</div>`}const h=r.seerr;if(h){const u=h.status==="connected";let a="";if(h.configuredServices){const e=h.configuredServices;a=`<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>`}m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"};">
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
|
|
<span style="font-weight: 500;">Seerr</span>
|
|
${A(h.status)}
|
|
</div>
|
|
${a}
|
|
</div>`}I.innerHTML=m}window.smartTestConnection=async function(v){const m=document.getElementById(`smart-${v}-url`),r=document.getElementById(`smart-${v}-key`),c=document.getElementById(`smart-${v}-status`),s=m?.value.trim(),h=r?.value.trim();if(!s||!h){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const a=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:v,url:s,apiKey:h})})).json();a.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(a.appName||"Connected")} v${escapeHtml(a.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(a.error)}</span>`}catch(u){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(u.message)}</span>`}};async function f(){B("progress"),k.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const v={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),s=document.getElementById(`smart-${r}-key`)?.value.trim();s&&c?v[r]={apiKey:s,url:c}:s&&(v[r]={apiKey:s})}const m={services:Object.keys(v).length>0?v:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)})).json();let s="";for(const h of c.steps||[]){const u=h.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',a=h.status==="success"?"var(--muted)":"var(--bad-fg)";s+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
${u}
|
|
<span>${escapeHtml(h.step)}</span>
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${a};">${escapeHtml(h.details||"")}</span>
|
|
</div>`}k.innerHTML=s,setTimeout(()=>p(c),500)}catch(r){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function p(v){B("results");const m=v.summary||{},r=m.failed===0&&m.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",s=r?"✓":"⚠",h=r?"All Connected!":`${escapeHtml(String(m.succeeded))}/${escapeHtml(String(m.totalSteps))} Steps Succeeded`;let u=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
|
<div style="font-size: 1.5rem; color: ${c};">${s}</div>
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${h}</div>
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(m.succeeded))} succeeded, ${escapeHtml(String(m.failed))} failed</div>
|
|
</div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const a of v.steps||[]){const e=a.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';u+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
${e} ${escapeHtml(a.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(a.details||"")}</span>
|
|
</div>`}u+="</div>",x.innerHTML=u,C.style.display=m.failed>0?"block":"none",v.steps?.some(a=>a.step.includes("Plex")&&a.status==="success")&&y()}async function y(){try{const m=await(await fetch("/api/v1/plex/libraries")).json();if(m.success&&m.libraries?.length>0){let r=`<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(m.serverName)} Libraries</h4>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of m.libraries){const s=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
${s} <strong>${escapeHtml(c.title)}</strong>
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
|
|
</div>`}r+="</div></div>",$.innerHTML=r,$.style.display="block"}}catch{}}E?.addEventListener("click",()=>{b.classList.add("show"),$.style.display="none",w()}),wireModal(b,N),S?.addEventListener("click",f),C?.addEventListener("click",f)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
|
<h3>\u{1F514} Notification Settings</h3>
|
|
|
|
<!-- 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 E=document.getElementById("notifications-modal"),N=document.getElementById("manage-notifications"),S=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const $=document.getElementById(`${x}-enabled`),C=document.getElementById(`${x}-config`);$?.addEventListener("change",()=>{C.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),L=document.getElementById("health-check-config");P?.addEventListener("change",()=>{L.style.opacity=P.checked?"1":"0.5"});async function H(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const C=$.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.containerDown!==!1,document.getElementById("event-container-up").checked=C.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=C.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=C.events?.deploymentFailed!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");$.success&&$.history?.length>0?C.innerHTML=$.history.map(R=>{const M=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);">${M}</div>
|
|
</div>
|
|
</div>
|
|
`}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function I(){try{const x={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,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}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();C.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function k(x){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();C.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),N?.addEventListener("click",()=>{E.classList.add("show"),H(),g()}),S?.addEventListener("click",I),wireModal(E,T)})(),(function(){document.addEventListener("click",b=>{const E=b.target.closest(".panel-tab");if(!E)return;const N=E.dataset.panel;if(!N)return;const S=E.closest(".panel-tabs"),T=S.closest(".weather-modal-content");S.querySelectorAll(".panel-tab").forEach(L=>L.classList.remove("active")),E.classList.add("active"),T.querySelectorAll(".panel-section").forEach(L=>L.classList.remove("active"));const P=T.querySelector("#"+N);P&&P.classList.add("active")})})(),(function(){var b=["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 E(){for(var e={},n=0;n<b.length;n++){var t=b[n],i=safeGet(t);i!=null&&(e[t]=i)}try{for(var o=0;o<localStorage.length;o++){var d=localStorage.key(o);/^widget-.+-enabled$/.test(d)&&(e[d]=localStorage.getItem(d))}}catch{}return e}function N(e){if(!e||typeof e!="object")return 0;var n=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),n++);return n}function S(e){return e.version&&!e.files&&e.services}function T(e){var n={};e.customServices&&(n["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(n["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(n["weather-zip"]=e.weatherZip),e.theme&&(n.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(n["user-themes"]=JSON.stringify(e.userThemes)),N(n),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 i=e.userThemes[t],o={};(window.THEME_PROPS||[]).forEach(function(d){i[d]&&(o[d]=i[d])}),secureFetch("/api/v1/themes/"+t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:i.name||t,colors:o})}).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"),L=document.getElementById("backup-restore-btn"),H=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),I=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),$=document.getElementById("backup-preview"),C=document.getElementById("backup-preview-content"),R=document.getElementById("backup-do-restore-btn"),M=document.getElementById("backup-result"),j=document.getElementById("backup-schedule-container"),B=document.getElementById("backup-history-container"),A=null;L?.addEventListener("click",function(){P.classList.add("show"),M&&(M.style.display="none"),$&&($.style.display="none"),x&&(x.style.display="none"),A=null}),wireModal(P,H),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"),n=await e.json();n.browserState=E();var t=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),o=document.createElement("a");o.href=i,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(i);var d=Object.keys(n.browserState).length,l=n.themes?Object.keys(n.themes).length:0;M.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+d+" browser settings"+(l?" + "+l+" themes":""),M.style.display="block",M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)"}catch(D){M.innerHTML="\u274C Export failed: "+escapeHtml(D.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),I?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){x.textContent="\u{1F4C4} "+n.name,x.style.display="block",M.style.display="none";try{var t=await n.text(),i=JSON.parse(t);if(S(i)){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),o+="</div>",C.innerHTML=o,$.style.display="block";return}var d=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await d.json();if(l.success){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var D in l.preview.files){var O=l.preview.files[D],F=O.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(O.description)+"</span>"}o+="</div>",l.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),C.innerHTML=o,$.style.display="block"}else M.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),M.style.display="block",M.style.background="color-mix(in srgb, #f39c12 15%, transparent)",M.style.border="1px solid #f39c12",$.style.display="none"}catch(q){M.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)",$.style.display="none"}}}),R?.addEventListener("click",async function(){if(A&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){R.disabled=!0,R.innerHTML='<span class="brand-spinner"></span> Restoring...';try{if(S(A)){T(A),M.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)",M.style.display="block",setTimeout(function(){location.reload()},2e3),R.disabled=!1,R.innerHTML="\u26A1 Restore Everything";return}var e=document.getElementById("backup-reload-caddy")?.checked??!0,n=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:A,options:{reloadCaddy:e}})}),t=await n.json(),i=0;if(A.browserState&&(i=N(A.browserState)),t.success){var o="\u2705 "+t.message;i>0&&(o+='<br><small style="color: var(--muted);">'+i+" browser settings restored</small>"),t.results.caddyReloaded&&(o+='<br><small style="color: var(--muted);">Caddy configuration reloaded</small>'),M.innerHTML=o,M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else M.innerHTML="\u26A0\uFE0F "+escapeHtml(t.message),i>0&&(M.innerHTML+='<br><small style="color: var(--muted);">'+i+" browser settings were restored</small>"),t.results?.errors?.length>0&&(M.innerHTML+="<br><small>"+t.results.errors.map(function(d){return escapeHtml(d.file)+": "+escapeHtml(d.error)}).join(", ")+"</small>"),M.style.background="color-mix(in srgb, #f39c12 15%, transparent)",M.style.border="1px solid #f39c12";M.style.display="block"}catch(d){M.innerHTML="\u274C Restore failed: "+escapeHtml(d.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)"}R.disabled=!1,R.innerHTML="\u26A1 Restore Everything"}});var w={type:"local"};async function z(){if(j)try{var e=await fetch("/api/v1/backups/config"),n=await e.json();if(!n.success)throw new Error(n.error||"Failed to load config");var t=n.config?.backups||{},i=Object.keys(t)[0],o=i?t[i]:null,d=o?.destinations&&o.destinations[0]||{type:"local"};w=JSON.parse(JSON.stringify(d));var l='<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;">';l+='<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">\u23F0 Backup Schedule</h4>',l+='<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">',l+='<div><label style="font-size: 0.8rem; color: var(--muted);">Schedule:</label>',l+=' <select id="backup-schedule-select" style="width: 100%;">',l+=' <option value="disabled"'+(o?.enabled?"":" selected")+">Disabled</option>",l+=' <option value="hourly"'+(o?.schedule==="hourly"?" selected":"")+">Hourly</option>",l+=' <option value="daily"'+(o?.schedule==="daily"?" selected":"")+">Daily</option>",l+=' <option value="weekly"'+(o?.schedule==="weekly"?" selected":"")+">Weekly</option>",l+=' <option value="monthly"'+(o?.schedule==="monthly"?" selected":"")+">Monthly</option>",l+=" </select></div>",l+='<div><label style="font-size: 0.8rem; color: var(--muted);">Keep last:</label>',l+=' <select id="backup-retention-select" style="width: 100%;">',l+=' <option value="3"'+(o?.retention?.keep===3?" selected":"")+">3 backups</option>",l+=' <option value="5"'+(!o?.retention||o?.retention?.keep===5?" selected":"")+">5 backups</option>",l+=' <option value="10"'+(o?.retention?.keep===10?" selected":"")+">10 backups</option>",l+=' <option value="30"'+(o?.retention?.keep===30?" selected":"")+">30 backups</option>",l+=" </select></div>",l+="</div>",l+='<div style="margin-top: 12px;">',l+=' <label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;">',l+=' <input type="checkbox" id="backup-encrypt-toggle"'+(o?.encrypt!==!1?" checked":"")+" />",l+=" Encrypt backups",l+=" </label></div>",l+='<div style="display: flex; gap: 8px; margin-top: 12px;">',l+=' <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>',l+=' <button id="backup-run-now" style="padding: 8px 16px; border-radius: 6px; cursor: pointer;">\u25B6\uFE0F Run Backup Now</button>',l+="</div>",l+="</div>",l+='<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;">',l+='<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">\u2601\uFE0F Backup Destination</h4>',l+='<div><label style="font-size: 0.8rem; color: var(--muted);">Where to store backups:</label>',l+=' <select id="backup-dest-type" style="width: 100%;">',l+=' <option value="local"'+(w.type==="local"?" selected":"")+">\u{1F4BE} Local disk</option>",l+=' <option value="dropbox"'+(w.type==="dropbox"?" selected":"")+">\u{1F4E6} Dropbox</option>",l+=' <option value="webdav"'+(w.type==="webdav"?" selected":"")+">\u{1F310} WebDAV (Nextcloud / ownCloud)</option>",l+=' <option value="sftp"'+(w.type==="sftp"?" selected":"")+">\u{1F510} SFTP</option>",l+=" </select></div>",l+='<div id="backup-dest-form" style="margin-top: 12px;"></div>',l+='<div id="backup-dest-result" style="display: none; margin-top: 10px; padding: 8px 10px; border-radius: 6px; font-size: 0.8rem;"></div>',l+="</div>",l+='<div id="backup-schedule-result" style="display: none; margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 0.85rem;"></div>',j.innerHTML=l,document.getElementById("backup-save-schedule")?.addEventListener("click",h),document.getElementById("backup-run-now")?.addEventListener("click",u);var D=document.getElementById("backup-dest-type");D?.addEventListener("change",function(){w={type:D.value},f(D.value)}),f(w.type)}catch(O){j.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: '+escapeHtml(O.message)+"</div>"}}async function f(e){var n=document.getElementById("backup-dest-form");if(n){if(e==="local"){n.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(w.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(w.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(w.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>",n.innerHTML=t,e==="sftp"){var i=document.getElementById("dest-sftp-authtype"),o=document.getElementById("dest-sftp-password-row"),d=document.getElementById("dest-sftp-key-row");i?.addEventListener("change",function(){i.value==="key"?(o.style.display="none",d.style.display=""):(o.style.display="",d.style.display="none")})}document.getElementById("dest-save-creds")?.addEventListener("click",function(){m(e)}),document.getElementById("dest-test-conn")?.addEventListener("click",function(){s(e)}),document.getElementById("dest-clear-creds")?.addEventListener("click",function(){r(e)}),await y(e)}}function p(e,n){var t=document.getElementById("backup-dest-result");t&&(t.innerHTML=e,t.style.display="block",t.style.background=n?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",t.style.border=n?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)")}async function y(e){try{var n=await fetch("/api/v1/backups/credentials/"+e),t=await n.json();if(!t.success||!t.credentials)return;var i=t.credentials;if(e==="dropbox"){var o=document.getElementById("dest-dropbox-token");o&&i.token&&(o.value=i.token)}else if(e==="webdav"){var d=document.getElementById("dest-webdav-url");d&&i.url&&(d.value=i.url);var l=document.getElementById("dest-webdav-username");l&&i.username&&(l.value=i.username);var D=document.getElementById("dest-webdav-password");D&&i.password&&(D.value=i.password)}else if(e==="sftp"){var O=document.getElementById("dest-sftp-host");O&&i.host&&(O.value=i.host);var F=document.getElementById("dest-sftp-port");F&&i.port&&(F.value=i.port);var q=document.getElementById("dest-sftp-username");q&&i.username&&(q.value=i.username);var U=document.getElementById("dest-sftp-password");U&&i.password&&(U.value=i.password);var G=document.getElementById("dest-sftp-privatekey");if(G&&i.privateKey&&(G.value=i.privateKey),i.privateKey){var W=document.getElementById("dest-sftp-authtype");W&&(W.value="key",W.dispatchEvent(new Event("change")))}}}catch{}}function v(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 n=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 n==="key"?t.privateKey=document.getElementById("dest-sftp-privatekey")?.value:t.password=document.getElementById("dest-sftp-password")?.value,t}return{}}async function m(e){try{var n=v(e),t=await secureFetch("/api/v1/backups/credentials/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)}),i=await t.json();p(i.success?"\u2705 Credentials saved":"\u26A0\uFE0F "+escapeHtml(i.error||"Failed"),i.success)}catch(o){p("\u274C "+escapeHtml(o.message),!1)}}async function r(e){if(confirm("Delete saved "+e+" credentials?"))try{var n=await secureFetch("/api/v1/backups/credentials/"+e,{method:"DELETE"}),t=await n.json();t.success?(p("\u2705 Credentials cleared",!0),f(e)):p("\u26A0\uFE0F "+escapeHtml(t.error||"Failed"),!1)}catch(i){p("\u274C "+escapeHtml(i.message),!1)}}function c(e){var n={type:e};return e==="local"||(e==="dropbox"?n.path=document.getElementById("dest-dropbox-path")?.value||"/dashcaddy-backups":e==="webdav"?n.path=document.getElementById("dest-webdav-path")?.value||"/dashcaddy-backups":e==="sftp"&&(n.path=document.getElementById("dest-sftp-path")?.value||"/dashcaddy-backups")),n}async function s(e){p('<span class="brand-spinner"></span> Testing connection...',!0);try{var n=c(e),t=await secureFetch("/api/v1/backups/test-destination",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)}),i=await t.json();if(i.success){var o=i.elapsedMs?" ("+i.elapsedMs+"ms)":"";p("\u2705 Connection OK"+o+" \u2014 write/read/delete probe succeeded",!0)}else p("\u274C "+escapeHtml(i.error||"Connection failed"),!1)}catch(d){p("\u274C "+escapeHtml(d.message),!1)}}async function h(){var e=document.getElementById("backup-schedule-select")?.value,n=parseInt(document.getElementById("backup-retention-select")?.value)||5,t=document.getElementById("backup-encrypt-toggle")?.checked??!0,i=document.getElementById("backup-dest-type")?.value||"local",o=document.getElementById("backup-schedule-result");try{var d=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:n},destinations:[c(i)]}}})}),l=await d.json();o&&(o.innerHTML=l.success?"\u2705 Schedule saved":"\u26A0\uFE0F "+escapeHtml(l.error),o.style.display="block",o.style.background=l.success?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",o.style.border=l.success?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)",setTimeout(function(){o&&(o.style.display="none")},3e3))}catch(D){o&&(o.innerHTML="\u274C "+escapeHtml(D.message),o.style.display="block",o.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",o.style.border="1px solid var(--bad-fg)")}}async function u(){var e=document.getElementById("backup-run-now"),n=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 i=await secureFetch("/api/v1/backups/execute",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({include:["all"],destinations:[c(t)]})}),o=await i.json();if(n){if(o.success){var d=o.backup?.size?(o.backup.size/1024/1024).toFixed(2):"?";n.innerHTML="\u2705 Backup complete ("+d+" MB)",n.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",n.style.border="1px solid var(--ok-fg)"}else n.innerHTML="\u26A0\uFE0F "+escapeHtml(o.error),n.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",n.style.border="1px solid var(--bad-fg)";n.style.display="block"}a()}catch(l){n&&(n.innerHTML="\u274C "+escapeHtml(l.message),n.style.display="block",n.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",n.style.border="1px solid var(--bad-fg)")}e&&(e.disabled=!1,e.innerHTML="\u25B6\uFE0F Run Backup Now")}async function a(){if(B){B.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';try{var e=await fetch("/api/v1/backups/history?limit=50"),n=await e.json();if(!n.success||!n.history?.length){B.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;">',i=0;i<n.history.length;i++){var o=n.history[i],d=o.size?(o.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(o.name||"backup")+"</span>",t+=' <div style="display: flex; align-items: center; gap: 8px;">',t+=' <span class="status-badge '+(o.status==="success"?"success":"down")+'">'+escapeHtml(o.status)+"</span>",o.status==="success"&&(t+=' <button class="backup-restore-btn" data-backup-id="'+escapeHtml(o.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(o.timestamp).toLocaleString()+" | "+d+" MB | "+(o.duration?(o.duration/1e3).toFixed(1)+"s":"--"),o.encrypted&&(t+=" | \u{1F512}"),t+=" </div>",t+="</div>"}t+="</div>",B.innerHTML=t,B.querySelectorAll(".backup-restore-btn").forEach(function(l){l.addEventListener("click",function(){window.__restoreServerBackup(l.dataset.backupId)})})}catch(l){B.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(l.message)+"</div>"}}}window.__restoreServerBackup=async function(e){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var n=await secureFetch("/api/v1/backups/restore/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),t=await n.json();t.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(t.error||"Unknown error"),"error")}catch(i){showNotification("Restore error: "+i.message,"error")}},document.querySelector('[data-panel="backup-automated"]')?.addEventListener("click",z),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",a)})(),(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 b=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),N=document.getElementById("stats-cancel"),S=document.getElementById("stats-refresh-btn"),T=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),L=document.getElementById("stats-aggregated-container"),H=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let I=null,k=null;function x(s){if(s===0||!s)return"0 B";const h=1024,u=["B","KB","MB","GB"],a=Math.floor(Math.log(s)/Math.log(h));return parseFloat((s/Math.pow(h,a)).toFixed(1))+" "+u[a]}function $(s){return s<30?"#2ecc71":s<70?"#f39c12":"#e74c3c"}function C(s){return s<50?"#2ecc71":s<80?"#f39c12":"#e74c3c"}async function R(){try{let s=null,h=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(s=e.stats,h=!0,k=e.stats)}catch{}if(!h){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){s={};for(const n of e.stats)s[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};k=s}}if(!s||Object.keys(s).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[a,e]of Object.entries(s)){const n=e.current||e,t=n.cpu?.percent||0,i=n.memory?.percent||0,o=$(t),d=C(i),l=n.memory?.usage||n.memory?.used||0,D=n.memory?.limit||0,O=n.network?.rxBytes||n.network?.rx||0,F=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;u+=`
|
|
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
|
<span style="font-weight: 600; flex: 1;">${e.name||a}</span>
|
|
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
|
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
|
<div>
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
<div style="height: 100%; width: ${Math.min(t,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
</div>
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${o}; 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(i,100)}%; background: ${d}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
</div>
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${d}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
|
|
</div>
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(l)} / ${x(D)}</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(O)}</span>
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
<span style="color: #e74c3c;">\u2191 ${x(F)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`}u+="</div>",P.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(s){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(s.message)}</div>`}}async function M(){if(!L)return;const s=k;if(!s||Object.keys(s).length===0){L.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 h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.aggregated;e&&(h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${a.name||u}</div>
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${e.memory?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg Mem</span></div>
|
|
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
|
</div>
|
|
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
|
|
</div>`)}h+="</div>",L.innerHTML=h}async function j(){if(!H)return;H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const s=k;if(!s||Object.keys(s).length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.alertConfig||{};h+=`<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;">${a.name||u}</span>
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
<input type="checkbox" class="alert-enabled" data-container="${u}" ${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="${u}" 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="${u}" 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="${u}" 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="${u}" ${e.autoRestart?"checked":""} /> Auto-restart on breach
|
|
</label>
|
|
<span style="flex: 1;"></span>
|
|
<button class="alert-save-btn" data-container="${u}" 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>`}h+="</div>",H.innerHTML=h,H.querySelectorAll(".alert-save-btn").forEach(u=>{u.addEventListener("click",async()=>{const a=u.dataset.container,e=H.querySelector(`.alert-enabled[data-container="${a}"]`)?.checked||!1,n=parseInt(H.querySelector(`.alert-cpu[data-container="${a}"]`)?.value)||80,t=parseInt(H.querySelector(`.alert-mem[data-container="${a}"]`)?.value)||85,i=parseInt(H.querySelector(`.alert-cooldown[data-container="${a}"]`)?.value)||15,o=H.querySelector(`.alert-autorestart[data-container="${a}"]`)?.checked||!1;try{const l=await(await secureFetch(`/api/v1/monitoring/alerts/${a}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:n,memoryThreshold:t,cooldownMinutes:i,autoRestart:o})})).json();u.textContent=l.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{u.textContent="Save"},2e3)}catch{u.textContent="\u274C Error",setTimeout(()=>{u.textContent="Save"},2e3)}})})}function B(){I&&clearInterval(I),T?.checked&&(I=setInterval(R,DC.POLL.STATS))}function A(){I&&(clearInterval(I),I=null)}E?.addEventListener("click",()=>{b.classList.add("show"),R(),B()}),N?.addEventListener("click",()=>{b.classList.remove("show"),A()}),b?.addEventListener("click",s=>{s.target===b&&(b.classList.remove("show"),A())}),S?.addEventListener("click",R),T?.addEventListener("change",()=>{T.checked?B():A()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",M),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const w=document.getElementById("stats-history-container"),z=document.getElementById("stats-history-container-area"),f=document.querySelectorAll(".stats-range-btn");let p="1h";function y(s){switch(s){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 v(s){return s==="raw"?"live (10s samples)":s==="hourly"?"hourly average":s==="daily"?"daily average":s}function m(s,h,u,a,e){if(!s||s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const n=s.map(h).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const t=Math.max(...n,1),i=Math.min(...n,0),o=t-i||1,d=600,l=80,D=4,O=(d-D*2)/Math.max(n.length-1,1),F=n.map((G,W)=>{const X=D+W*O,Q=l-D-(G-i)/o*(l-D*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],U=n.reduce((G,W)=>G+W,0)/n.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(a)}</span>
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e}</span>
|
|
</div>
|
|
<svg viewBox="0 0 ${d} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
|
<polyline fill="none" stroke="${u}" stroke-width="1.5" points="${F}" />
|
|
</svg>
|
|
</div>
|
|
`}function r(){if(!w)return;const s=k||{},h=w.value,u=Object.entries(s);if(u.length===0){w.innerHTML='<option value="">No containers</option>';return}w.innerHTML=u.map(([a,e])=>`<option value="${escapeHtml(a)}">${escapeHtml(e.name||a)}</option>`).join(""),h&&s[h]&&(w.value=h)}async function c(){if(!z||!w)return;const s=w.value;if(!s){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const h=Date.now(),u=h-y(p);z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(s)}?startTime=${u}&endTime=${h}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],t=e.tier||"raw";if(n.length===0){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${p}. Tier: ${v(t)}.</div>`;return}const i=t==="raw",o=i?F=>F.cpu?.percent:F=>F.cpu?.avg,d=i?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=i?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,D=i?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let O=`
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
|
${n.length} samples \xB7 ${escapeHtml(v(t))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(h).toLocaleString()}
|
|
</div>
|
|
`;O+=m(n,o,"#2ecc71","CPU","%"),O+=m(n,d,"#3498db","Memory","%"),O+=m(n,l,"#9b59b6","Network RX"," MB"),O+=m(n,D,"#e67e22","Network TX"," MB"),z.innerHTML=O}catch(a){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(a.message)}</div>`}}f.forEach(s=>{s.addEventListener("click",()=>{f.forEach(h=>h.classList.remove("active")),s.classList.add("active"),p=s.dataset.range,c()})}),w?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
|
<h3>\u{1F3E5} Health Check Dashboard</h3>
|
|
<p class="modal-subtitle">
|
|
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 b=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),N=document.getElementById("health-cancel"),S=document.getElementById("health-refresh-btn"),T=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),L=document.getElementById("health-config-container"),H=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),I=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),$=document.getElementById("health-form-save");let C=null;function R(f){return f>=99.9?"var(--ok-fg)":f>=95?"#f39c12":"var(--bad-fg)"}function M(f){const p={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: ${p[f]||"var(--muted)"}20; color: ${p[f]||"var(--muted)"};">${f}</span>`}async function j(){try{const p=await(await fetch("/api/v1/health-checks/status")).json();if(!p.success||!p.status||Object.keys(p.status).length===0){T.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(p.status);let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',v+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',v+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',v+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of y){const r=m.status==="up",c=r?"var(--dot-ok)":"var(--dot-bad)",s=m.uptime?.["24h"]??"-",h=m.uptime?.["7d"]??"-",u=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",a=m.timestamp?timeAgo(m.timestamp):"-";v+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px; color: ${typeof s=="number"?R(s):"var(--muted)"};">${typeof s=="number"?s.toFixed(1)+"%":s}</td>`,v+=`<td style="padding: 8px; color: ${typeof h=="number"?R(h):"var(--muted)"};">${typeof h=="number"?h.toFixed(1)+"%":h}</td>`,v+=`<td style="padding: 8px;">${u}</td>`,v+=`<td style="padding: 8px; color: var(--muted);">${a}</td>`,v+="</tr>",v+=`<tr id="health-detail-${escapeHtml(m.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}v+="</table>",T.innerHTML=v,H.textContent="Updated "+new Date().toLocaleTimeString(),T.querySelectorAll("tr[data-health-id]").forEach(m=>{m.addEventListener("click",async()=>{const r=m.dataset.healthId,c=document.getElementById("health-detail-"+r);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const h=await(await fetch(`/api/v1/health-checks/${r}/stats?hours=24`)).json();if(h.success&&h.stats){const u=h.stats,a=u.responseTime||{};c.querySelector("td").innerHTML=`
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${u.totalChecks||0}</strong></div>
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${R(u.uptime||0)};">${(u.uptime||0).toFixed(2)}%</strong></div>
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(a.avg||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(a.p95||0)}ms / ${Math.round(a.p99||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(a.min||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(a.max||0)}ms</strong></div>
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${u.upChecks||0}</strong></div>
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${u.downChecks||0}</strong></div>
|
|
</div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(s){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`}}})})}catch(f){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(f.message)}</div>`}}async function B(){try{const[f,p]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),y=await f.json(),v=await p.json();let m="";const r=y.success&&y.incidents?y.incidents:[];if(r.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+r.length+")</h4>";for(const s of r)m+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<span style="font-weight: 500;">${escapeHtml(s.serviceId)}</span>
|
|
<span>${M(s.severity)}</span>
|
|
</div>
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(s.message)}</div>
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(s.createdAt)} \xB7 ${s.occurrences||1} occurrence(s)</div>
|
|
</div>`;m+="</div>"}else m+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const c=v.success&&v.history?v.history:[];if(c.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const s of c){const h=s.status==="resolved",u=h&&s.duration?s.duration<6e4?Math.round(s.duration/1e3)+"s":Math.round(s.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(s.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(s.type)}</td>`,m+=`<td style="padding: 6px;">${M(s.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${h?"var(--ok-fg)":"var(--bad-fg)"};">${s.status}</span></td>`,m+=`<td style="padding: 6px;">${u}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(s.createdAt)}</td>`,m+="</tr>"}m+="</table>"}P.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(f){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}async function A(){try{const p=await(await fetch("/api/v1/health-checks/status")).json(),y=p.success&&p.status?Object.values(p.status):[];if(y.length===0){L.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 v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const m of y){const r=m.status==="up";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px; color: ${r?"var(--ok-fg)":"var(--bad-fg)"};">${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,v+='<td style="padding: 8px; text-align: right;">',v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,v+="</td></tr>"}v+="</table>",L.innerHTML=v}catch(f){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}function w(f,p,y,v,m,r,c){C=f||null,k.textContent=f?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=f||"",document.getElementById("health-form-id").disabled=!!f,document.getElementById("health-form-name").value=p||"",document.getElementById("health-form-url").value=y||"",document.getElementById("health-form-timeout").value=v||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=r||99.9,document.getElementById("health-form-slow").value=c||5e3,I.style.display="",g.style.display="none"}function z(){I.style.display="none",g.style.display="",C=null}g?.addEventListener("click",()=>w("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",z),$?.addEventListener("click",async()=>{const f=C||document.getElementById("health-form-id").value.trim();if(!f)return showNotification("Service ID is required","warning");const p=document.getElementById("health-form-url").value.trim();if(!p)return showNotification("URL is required","warning");const y=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),v={name:document.getElementById("health-form-name").value.trim()||f,url:p,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{$.textContent="Saving...",$.disabled=!0;const r=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(f)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)})).json();if(!r.success)throw new Error(r.error||"Save failed");z(),A(),j()}catch(m){showNotification("Error: "+m.message,"error")}finally{$.textContent="Save",$.disabled=!1}}),document.addEventListener("health-edit",async f=>{const p=f.detail;w(p,"","",1e4,"200",99.9,5e3)}),document.addEventListener("health-delete",async f=>{const p=f.detail;if(confirm(`Delete health check for "${p}"?`))try{const v=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(p)}/configure`,{method:"DELETE"})).json();if(!v.success)throw new Error(v.error);A(),j()}catch(y){showNotification("Error: "+y.message,"error")}}),E?.addEventListener("click",()=>{b?.classList.add("show"),j()}),wireModal(b,N),S?.addEventListener("click",j),document.querySelector('[data-panel="health-incidents"]')?.addEventListener("click",B),document.querySelector('[data-panel="health-config"]')?.addEventListener("click",A)})(),(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 b=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),N=document.getElementById("updates-cancel"),S=document.getElementById("updates-check-btn"),T=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),L=document.getElementById("updates-auto-container"),H=document.getElementById("updates-last-check");async function g(){try{const u=await(await fetch("/api/v1/updates/available")).json();if(!u.success)throw new Error(u.error);const a=u.updates||[];if(a.length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',H.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 n of a)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.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(n.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",T.innerHTML=e,H.textContent=a.length+" update(s) available",T.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Update "${i}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(d.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Update failed")}catch(o){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+o.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),T.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Rollback "${i}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(d.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Rollback failed")}catch(o){n.textContent="Failed",showNotification("Rollback error: "+o.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(h){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}async function I(){S.textContent="\u{1F50D} Checking...",S.disabled=!0;try{const u=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!u.success)throw new Error(u.error);S.textContent="\u2705 Done!",await g()}catch(h){S.textContent="\u274C Failed",showNotification("Check error: "+h.message,"error")}setTimeout(()=>{S.textContent="\u{1F50D} Check for Updates",S.disabled=!1},3e3)}async function k(){try{P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const u=await(await fetch("/api/v1/updates/history?limit=50")).json(),a=u.success&&u.history?u.history:[];if(a.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 n of a){const t=n.status==="success",i=n.duration?n.duration<1e3?n.duration+"ms":Math.round(n.duration/1e3)+"s":"-";e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(n.timestamp)}</td>`,e+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 6px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 6px;">${i}</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&&n.error&&(e+=`<tr><td colspan="5" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">${escapeHtml(n.error)}</td></tr>`)}e+="</table>",P.innerHTML=e}catch(h){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}async function x(){try{L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const[h,u]=await Promise.all([fetch("/api/v1/stats/containers"),fetch("/api/v1/updates/auto-update")]),a=await h.json(),e=await u.json(),n=a.success&&a.stats?a.stats:[],t=e.success&&e.config?e.config:{};if(n.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F916}</span>No running containers found.</div>';return}let i='<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>';i+='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">',i+='<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 o of n){const d=o.name||o.Names?.[0]?.replace(/^\//,"")||o.Id?.substring(0,12),l=o.containerId||o.Id,D=t[l]||{},O=D.enabled?D.schedule||"weekly":"",F=D.autoRollback!==!1,q=D.maintenanceWindow||"",U=D.lastAutoUpdate?timeAgo(D.lastAutoUpdate):"Never";i+=`<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(l)}">`,i+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(d)}</td>`,i+=`<td style="padding: 8px;">
|
|
<select class="auto-schedule" data-id="${escapeHtml(l)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
<option value=""${O?"":" selected"}>Disabled</option>
|
|
<option value="daily"${O==="daily"?" selected":""}>Daily</option>
|
|
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
|
|
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
|
|
</select></td>`,i+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(l)}" value="${escapeHtml(q)}" 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>`,i+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(l)}"${F?" checked":""} /></td>`,i+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,i+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(l)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,i+="</tr>"}i+="</table>",L.innerHTML=i,L.querySelectorAll(".save-auto-btn").forEach(o=>{o.addEventListener("click",async()=>{const d=o.dataset.id,l=o.closest("tr"),D=l.querySelector(".auto-schedule").value,O=l.querySelector(".auto-rollback").checked,F=l.querySelector(".auto-window").value.trim();o.textContent="Saving...",o.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!D,schedule:D||"weekly",autoRollback:O,maintenanceWindow:F||void 0})})).json();if(U.success)o.textContent="\u2713 Saved";else throw new Error(U.error)}catch(q){o.textContent="\u2717 Error",showNotification("Save error: "+q.message,"error")}setTimeout(()=>{o.textContent="Save",o.disabled=!1},2e3)})})}catch(h){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),R=document.getElementById("dashcaddy-update-details"),M=document.getElementById("dashcaddy-new-version"),j=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),A=document.getElementById("dashcaddy-check-btn"),w=document.getElementById("dashcaddy-rollback-btn"),z=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let p=null;function y(h,u){z&&(z.style.display="block",z.style.background=u==="error"?"var(--bad-bg)":u==="success"?"var(--ok-bg)":"var(--bg)",z.style.color=u==="error"?"var(--bad-fg)":u==="success"?"var(--ok-fg)":"var(--fg)",z.textContent=h)}async function v(){try{const u=await(await fetch("/api/v1/system/version")).json();if(u.success){const a=u.commit&&u.commit!=="unknown"?u.commit:null;$.textContent="v"+u.version+(a?" ("+a.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function m(h){h||(A.textContent="Checking...",A.disabled=!0);try{const a=await(await fetch("/api/v1/system/update-check")).json();if(p=a,a.success&&a.available&&a.remote){C.style.display="",R.style.display="",M.textContent="v"+a.remote.version,j.textContent=a.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 n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.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;",n.appendChild(t)}}else C.style.display="none",R.style.display="none",await v(),h||y("You are running the latest version.","success");h||(A.textContent="Check for Updates",A.disabled=!1)}catch(u){h||(y("Failed to check: "+u.message,"error"),A.textContent="Check for Updates",A.disabled=!1)}}async function r(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,y("Downloading and applying update...","info");try{const u=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(u.success)return y("Update initiated: v"+(u.fromVersion||"?")+" \u2192 v"+(u.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(a=>a.remove()),!0;throw new Error(u.error||"Update failed")}catch(h){throw y("Update failed: "+h.message,"error"),B.textContent="Update Now",B.disabled=!1,h}}async function c(){try{const u=await(await fetch("/api/v1/system/update-history")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){f.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 n of a){const t=n.status==="success"?"\u2713 success":n.status==="pending"?"\u23F3 pending":n.status==="partial"?"\u26A0 partial":"\u2717 "+n.status,i=n.status==="success"?"var(--ok-fg)":n.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(n.timestamp)+"</td>",e+='<td style="padding: 6px; font-weight: 500;">v'+escapeHtml(n.version)+(n.rollback?" (rollback)":"")+"</td>",e+='<td style="padding: 6px; color: var(--muted);">v'+escapeHtml(n.fromVersion||"?")+"</td>",e+='<td style="padding: 6px;"><span style="color: '+i+';">'+t+"</span></td>",e+="</tr>",n.error&&(e+='<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">'+escapeHtml(n.error)+"</td></tr>"),n.note&&(e+='<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--muted);">'+escapeHtml(n.note)+"</td></tr>")}e+="</table>",f.innerHTML=e}catch(h){f.innerHTML='<div class="panel-empty" style="color: var(--bad-fg);">Failed: '+escapeHtml(h.message)+"</div>"}}async function s(){try{const u=await(await fetch("/api/v1/system/rollback-versions")).json(),a=u.success&&u.versions?u.versions:[];if(a.length===0){showNotification("No rollback versions available.","info");return}const e=prompt(`Available rollback versions:
|
|
`+a.join(`
|
|
`)+`
|
|
|
|
Enter version to rollback to:`);if(!e)return;if(!a.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(h){y("Rollback failed: "+h.message,"error")}}A?.addEventListener("click",()=>m(!1)),B?.addEventListener("click",()=>r().catch(()=>{})),w?.addEventListener("click",s),S?.addEventListener("click",I),E?.addEventListener("click",()=>{b?.classList.add("show"),g()}),wireModal(b,N),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),c(),p||m(!0)}),window.dcApplyUpdate=r,window.dcCheckForUpdate=m,setTimeout(()=>m(!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 b=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),N=document.getElementById("dr-close");function S(H){if(!H||H===0)return"0 B";const g=["B","KB","MB","GB","TB"],I=Math.floor(Math.log(Math.abs(H))/Math.log(1024));return(H/Math.pow(1024,I)).toFixed(1)+" "+g[I]}async function T(){const H=document.getElementById("dr-vol-list");try{const I=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of I){const $=x.name==="buildkit"||x.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.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`),T()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-vol-name"),g=H.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),H.value="",showNotification(`Volume "${g}" created`,"success"),T()}catch(I){showNotification("Create failed: "+I.message,"error")}});async function P(){const H=document.getElementById("dr-net-list");try{const I=(await getJSON("/api/v1/docker/networks")).networks||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of I){const $=["bridge","host","none"].includes(x.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<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>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.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($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),I=H.value.trim();if(!I){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:I,driver:g.value}),H.value="",showNotification(`Network "${I}" created`,"success"),P()}catch(k){showNotification("Create failed: "+k.message,"error")}});async function L(){const H=document.getElementById("dr-disk-content");try{const g=await getJSON("/api/v1/docker/disk-usage"),I=[{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 k=`<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 16px;">Total: ${S(g.totalSize)}</div>`;k+='<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">';for(const x of I)k+='<div style="padding: 14px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border);">',k+=`<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>`,k+=`<div style="font-size: 1.1rem; font-weight: 600; color: var(--accent);">${S(x.size)}</div>`,x.reclaimable>0&&(k+=`<div style="font-size: 0.78rem; color: var(--muted);">Reclaimable: ${S(x.reclaimable)}</div>`),x.extra&&(k+=`<div style="font-size: 0.78rem; color: var(--muted);">${x.extra}</div>`),k+="</div>";k+="</div>",H.innerHTML=k}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}E?.addEventListener("click",()=>{b?.classList.add("show"),T()}),wireModal(b,N),document.querySelector('[data-panel="dr-networks"]')?.addEventListener("click",P),document.querySelector('[data-panel="dr-disk"]')?.addEventListener("click",L)})(),(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 b=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),N=document.getElementById("compose-cancel");wireModal(b,N);let S=null;function T(L){document.getElementById("compose-step-paste").style.display=L==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=L==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=L==="progress"?"":"none"}E?.addEventListener("click",()=>{T("paste"),S=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",L=>{const H=L.target.files[0];if(!H)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(H)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const L=document.getElementById("compose-yaml").value.trim(),H=document.getElementById("compose-stack-name").value.trim()||"stack";if(!L){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),I=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:L,stackName:H});S=k,S.stackName=H,P(k),T("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{g.textContent=I,g.disabled=!1}});function P(L){const H=document.getElementById("compose-preview-content");let g="";L.networks&&L.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${L.networks.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),L.volumes&&L.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${L.volumes.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${L.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const I of L.services){const k=I.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(I.name)}`,I.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(I.reason)}</span>`),g+="</div>",!I.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(I.image)}</code></div>`,I.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${I.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),I.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${I.volumes.length}</div>`),Object.keys(I.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(I.environment).length}</div>`),I.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(I.envFileWarning)}</div>`),I.resources?.cpus||I.resources?.memory)){const x=[];I.resources.cpus&&x.push(`CPU: ${I.resources.cpus}`),I.resources.memory&&x.push(`Mem: ${I.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}g+="</div>"}g+="</div>",H.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>T("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!S)return;const L=document.getElementById("compose-deploy-btn");L.textContent="Deploying...",L.disabled=!0,T("progress");const H=document.getElementById("compose-progress-content");H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:S.services,networks:S.networks,stackName:S.stackName});let I=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;I+='<div class="scroll-container" style="max-height: 350px;">';for(const k of g.results){const x=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";I+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',I+=`${x} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&(I+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&(I+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&(I+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),I+="</div>"}I+="</div>",I+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>',H.innerHTML=I,document.getElementById("compose-done-btn")?.addEventListener("click",()=>{b?.classList.remove("show"),typeof window.loadServices=="function"&&window.loadServices().then(()=>{typeof window.buildGrid=="function"&&window.buildGrid()})}),showNotification(`Stack "${g.stackName}" deployed`,"success")}catch(g){H.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",()=>T("paste"))}finally{L.textContent="Deploy All",L.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 b=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),N=document.getElementById("exec-close");let S=null,T=null,P=null;function L(){if(T){try{T.close()}catch{}T=null}if(S){try{S.dispose()}catch{}S=null}P=null,E.innerHTML=""}function H(g,I){if(L(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${I||g}`,b?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}S=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,S.loadAddon(P)),S.open(E),P&&setTimeout(()=>P.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";T=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(g)}`),T.binaryType="arraybuffer",T.onopen=()=>{if(S.writeln("\x1B[32mConnecting...\x1B[0m"),P){const $=P.proposeDimensions();$&&T.send(JSON.stringify({type:"resize",cols:$.cols,rows:$.rows}))}},T.onmessage=$=>{if(typeof $.data=="string"){try{const C=JSON.parse($.data);if(C.type==="connected"){S.writeln(`\x1B[32mConnected (${C.shell})\x1B[0m\r
|
|
`);return}if(C.type==="error"){S.writeln(`\x1B[31mError: ${C.message}\x1B[0m`);return}if(C.type==="exit"){S.writeln(`\r
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}S.write($.data)}else S.write(new Uint8Array($.data))},T.onclose=()=>{S&&S.writeln(`\r
|
|
\x1B[33mDisconnected.\x1B[0m`)},T.onerror=()=>{S&&S.writeln(`\r
|
|
\x1B[31mConnection error.\x1B[0m`)},S.onData($=>{T&&T.readyState===WebSocket.OPEN&&T.send($)}),S.onResize(({cols:$,rows:C})=>{T&&T.readyState===WebSocket.OPEN&&T.send(JSON.stringify({type:"resize",cols:$,rows:C}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),b._resizeHandler=x}N?.addEventListener("click",()=>{L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",g=>{g.target===b&&(L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=H})(),(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 b=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),N=document.getElementById("audit-cancel"),S=document.getElementById("audit-refresh-btn"),T=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),L=document.getElementById("audit-log-container"),H=document.getElementById("audit-load-more");let g=0;const I=50;async function k(x){try{x||(g=0,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const $=P.value;let C=`/api/v1/audit-logs?limit=${I}&offset=${g}`;$&&(C+=`&action=${encodeURIComponent($)}`);const M=await(await fetch(C)).json(),j=M.success&&M.entries?M.entries:[];if(j.length===0&&!x){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',H.style.display="none";return}let B="";x||(B='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',B+='<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 A of j){const w=A.outcome==="success";B+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',B+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(A.timestamp)}</td>`,B+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(A.ip||"-")}</td>`,B+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(A.action||"-")}</td>`,B+=`<td style="padding: 6px;">${escapeHtml(A.resource||"-")}</td>`,B+=`<td style="padding: 6px;"><span style="color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"\u2713":"\u2717"}</span></td>`,B+="</tr>",A.details&&Object.keys(A.details).length>0&&(B+=`<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(A.details,null,2))}</pre></td></tr>`)}if(!x)B+="</table>",L.innerHTML=B;else{const A=L.querySelector("table");A&&A.insertAdjacentHTML("beforeend",B)}g+=j.length,H.style.display=j.length>=I?"":"none",L.querySelectorAll(".audit-row").forEach(A=>{A.dataset.wired||(A.dataset.wired="true",A.addEventListener("click",()=>{const w=A.nextElementSibling;w&&w.classList.contains("audit-detail")&&(w.style.display=w.style.display==="none"?"":"none")}))})}catch($){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml($.message)}</div>`}}E?.addEventListener("click",()=>{b?.classList.add("show"),k(!1)}),wireModal(b,N),S?.addEventListener("click",()=>k(!1)),P?.addEventListener("change",()=>k(!1)),H?.addEventListener("click",()=>k(!0)),T?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const $=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();$.success?k(!1):showNotification("Error: "+($.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
<label for="weather-location-input">Location:</label>
|
|
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
|
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
|
<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 E="weather-location",N="weather-zip",S="weather-geo",T="weather-unit";!safeGet(E)&&safeGet(N)&&safeSet(E,safeGet(N));function P(){return safeGet(T)||"imperial"}function L(){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 H={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},I=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(B){return I[Math.round(B/22.5)%16]}async function x(B){const A=safeGet(S);if(A)try{const y=JSON.parse(A);if(y.query===B)return y}catch{}const w=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(B)}&count=1&language=en&format=json`);if(!w.ok)throw new Error("Geocoding failed");const z=await w.json();if(!z.results||!z.results.length)throw new Error("Location not found");const f=z.results[0],p={query:B,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(S,JSON.stringify(p)),p}function $(B){return B.countryCode==="US"&&B.state?`${B.city}, ${B.state}`:B.country?`${B.city}, ${B.country}`:B.city}async function C(B){try{const A=await x(B),w=P(),z=w==="metric"?"celsius":"fahrenheit",f=w==="metric"?"kmh":"mph",p=`https://api.open-meteo.com/v1/forecast?latitude=${A.lat}&longitude=${A.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${z}&wind_speed_unit=${f}`,y=await fetch(p);if(!y.ok)throw new Error("Weather fetch failed");const m=(await y.json()).current,r=m.weather_code;return{temp:Math.round(m.temperature_2m),condition:H[r]||"Unknown",icon:g[r]||"\u{1F324}\uFE0F",locationStr:$(A),windSpeed:Math.round(m.wind_speed_10m),windDir:k(m.wind_direction_10m),unit:w}}catch(A){return console.warn("Weather fetch failed:",A),null}}async function R(){const B=L();if(!B.icon||!B.temp||!B.condition||!B.location||!B.wind){console.warn("Weather widget elements not found");return}const A=safeGet(E);if(!A){B.location.textContent="Set Location",B.temp.textContent="--\xB0",B.condition.textContent="Click \u2699\uFE0F to configure",B.wind.textContent="--",B.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const w=await C(A);if(w){const z=w.unit==="metric"?"\xB0C":"\xB0F",f=w.unit==="metric"?"km/h":"mph";B.location.textContent=w.locationStr,B.temp.textContent=`${w.temp}${z}`,B.condition.textContent=w.condition,B.wind.textContent=`Wind: ${w.windSpeed} ${f} ${w.windDir}`,B.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(w.icon)}</span>`}}catch(w){b.logError("[Weather] Update Error",w,{function:"updateWeather"}),B.location.textContent="Weather Error",B.temp.textContent="Error",B.condition.textContent="Failed to load",B.wind.textContent="--"}}const M=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const B=P(),A=M.querySelector(`input[name="weather-unit-radio"][value="${B}"]`);A&&(A.checked=!0),M.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const B=j.value.trim();if(B){safeGet(E)!==B&&safeSet(S,""),safeSet(E,B);const w=M.querySelector('input[name="weather-unit-radio"]:checked'),z=w?w.value:"imperial",f=P();safeSet(T,z),f!==z&&safeSet(S,""),M.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(M),document.addEventListener("keydown",B=>{B.key==="Escape"&&M.classList.contains("show")&&M.classList.remove("show")}),R(),setInterval(R,DC.POLL.WEATHER)})(),(function(){const b=document.getElementById("clock-widget"),E=document.getElementById("clock-render");if(!b||!E)return;const N=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],S=["January","February","March","April","May","June","July","August","September","October","November","December"],T=["XII","I","II","III","IV","V","VI","VII","VIII","IX","X","XI"];let P=safeGet("clock-style")||"default",L=-1,H=!1,g="",I="",k=null,x=null;function $(a){if(H||safeGet("clock-chimes")!=="true")return;H=!0;const e=parseInt(safeGet("clock-chime-volume")||"50",10)/100;let n=0;function t(){if(n>=a){H=!1;return}const i=new Audio("/assets/sounds/church-bell.mp3");i.volume=e,i.play().catch(()=>{}),n++,n<a?setTimeout(t,2500):setTimeout(()=>{H=!1},2500)}t()}function C(a){return N[a.getDay()]+", "+S[a.getMonth()]+" "+a.getDate()+", "+a.getFullYear()}function R(){I="",k=null}function M(){return I!=="digital"&&(E.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>',k={main:E.querySelector(".clock-main"),seconds:E.querySelector(".clock-seconds"),ampm:E.querySelector(".clock-ampm"),date:E.querySelector(".clock-date")},I="digital"),k}function j(a){const e=a.getHours(),n=a.getMinutes(),t=a.getSeconds(),i=e>=12?"PM":"AM",o=e%12||12,d=M();d.main.textContent=`${o}:${String(n).padStart(2,"0")}`,d.seconds.textContent=`:${String(t).padStart(2,"0")}`,d.ampm.textContent=i,d.date.textContent=C(a)}function B(a,e){const n=a.getHours(),t=a.getMinutes(),i=a.getSeconds(),o=n>=12?"PM":"AM",d=n%12||12,l=M();l.main.textContent=`${String(d).padStart(2,"0")}:${String(t).padStart(2,"0")}`,l.seconds.textContent=`:${String(i).padStart(2,"0")}`,l.ampm.textContent=o,l.date.textContent=C(a)}function A(a){const e=a.getHours(),n=a.getMinutes(),t=a.getSeconds(),i=e>=12?"PM":"AM",o=e%12||12,d=String(o).padStart(2," ")+String(n).padStart(2,"0")+String(t).padStart(2,"0");let l='<div class="flip-clock-row">';if(l+=w(d[0],0),l+=w(d[1],1),l+='<span class="flip-colon">:</span>',l+=w(d[2],2),l+=w(d[3],3),l+='<span class="flip-colon">:</span>',l+=w(d[4],4),l+=w(d[5],5),l+=`<span class="flip-ampm">${i}</span>`,l+="</div>",l+=`<div class="clock-date">${C(a)}</div>`,E.innerHTML=l,I="flip",g){for(let D=0;D<6;D++)if(d[D]!==g[D]){const O=E.querySelector(`.flip-card[data-idx="${D}"]`);O&&O.classList.add("flipping")}}g=d}function w(a,e){const n=a===" "?"":a;return`<div class="flip-card" data-idx="${e}"><div class="flip-top">${n}</div><div class="flip-bottom">${n}</div></div>`}function z(a){const e=a.getHours(),n=a.getMinutes(),t=a.getSeconds(),i=e%12||12,o=e>=12?"PM":"AM",d=[Math.floor(i/10),i%10,Math.floor(n/10),n%10,Math.floor(t/10),t%10];let l='<div class="binary-clock">';l+='<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 D=3;D>=0;D--){l+='<div class="binary-row">';for(let O=0;O<6;O++){const F=d[O]>>D&1;l+=`<div class="binary-dot ${F?"on":""}"></div>`}l+="</div>"}l+='<div class="binary-values">';for(let D=0;D<6;D++)l+=`<span>${d[D]}</span>`;l+="</div>",l+=`<div class="binary-ampm">${o}</div>`,l+="</div>",l+=`<div class="clock-date">${C(a)}</div>`,E.innerHTML=l,I="binary"}function f(a,e){const n=a.getHours(),t=a.getMinutes(),i=a.getSeconds(),o=120,d=o/2,l=o/2,D=i/60*360-90,O=(t+i/60)/60*360-90,F=(n%12+t/60)/12*360-90;let q="";for(let X=1;X<=12;X++){const Q=X/12*2*Math.PI-Math.PI/2,ne=47,oe=d+ne*Math.cos(Q),se=l+ne*Math.sin(Q),Y=e?T[X%12]:X;q+=`<text x="${oe}" y="${se}" text-anchor="middle" dominant-baseline="central" fill="var(--fg)" font-size="${e?"7":"9"}" font-weight="600" font-family="'Sami Grotesk',sans-serif">${Y}</text>`}let U="";for(let X=0;X<60;X++){const Q=X/60*2*Math.PI-Math.PI/2,ne=56,oe=X%5===0?52:54,se=d+oe*Math.cos(Q),Y=l+oe*Math.sin(Q),ie=d+ne*Math.cos(Q),re=l+ne*Math.sin(Q),ae=X%5===0?1.5:.5;U+=`<line x1="${se}" y1="${Y}" x2="${ie}" y2="${re}" stroke="var(--muted)" stroke-width="${ae}" stroke-linecap="round"/>`}const G=`<svg class="analog-clock-svg" viewBox="0 0 ${o} ${o}" width="${o}" height="${o}">
|
|
<circle cx="${d}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
${U}
|
|
${q}
|
|
<line x1="${d}" y1="${l}" x2="${d+28*Math.cos(F*Math.PI/180)}" y2="${l+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
<line x1="${d}" y1="${l}" x2="${d+38*Math.cos(O*Math.PI/180)}" y2="${l+38*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
<line x1="${d}" y1="${l}" x2="${d+42*Math.cos(D*Math.PI/180)}" y2="${l+42*Math.sin(D*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
<circle cx="${d}" cy="${l}" r="3" fill="var(--fg)"/>
|
|
</svg>`,W=a.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${a.getHours()%12||12}:${String(t).padStart(2,"0")} ${W}</span><span class="analog-date-sm">${C(a)}</span></div></div>`,I="analog"}function p(){const a=new Date,e=a.getHours()%12||12,n=a.getMinutes(),t=a.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==i&&(b.className=i),P){case"lcd":B(a);break;case"lcd-blue":B(a);break;case"lcd-amber":B(a);break;case"lcd-retro":B(a);break;case"lcd-taxi":B(a);break;case"flip":A(a);break;case"binary":z(a);break;case"analog":f(a,!1);break;case"roman":f(a,!0);break;default:j(a)}n===0&&t===0&&e!==L&&(L=e,$(e)),n!==0&&(L=-1)}function y(){clearTimeout(x);const a=document.hidden?6e4:1e3,e=a-Date.now()%a+25;x=setTimeout(()=>{p(),y()},e)}document.addEventListener("visibilitychange",()=>{g="",R(),p(),y()}),p(),y();const v=[{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 m='<div class="clock-style-grid">';v.forEach(a=>{m+=`<label class="clock-style-option">
|
|
<input type="radio" name="clock-style-radio" value="${a.id}">
|
|
<span class="clock-style-card"><span class="clock-style-icon">${a.icon}</span><span class="clock-style-label">${a.label}</span></span>
|
|
</label>`}),m+="</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>
|
|
${m}
|
|
</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 r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),s=document.getElementById("clock-chime-volume"),h=document.getElementById("clock-volume-section");function u(){const a=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${a}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",s.value=safeGet("clock-chime-volume")||"50",h.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{h.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const a=parseInt(s.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=a,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const a=r.querySelector('input[name="clock-style-radio"]:checked'),e=a?a.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",s.value),P=e,g="",R(),p(),y(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(a=>{a.addEventListener("change",()=>{P=a.value,g="",R(),p()})})})(),(function(){async function b(){try{const L=await(await fetch("/api/v1/health-checks/status")).json();if(!L.success||!L.status)return;for(const[H,g]of Object.entries(L.status)){const I=document.getElementById("uptime-"+H),k=document.getElementById("uptime-bar-"+H);if(!I)continue;const x=g.uptime?.["24h"];if(x!=null){const $=x.toFixed(1);I.textContent=`${$}% uptime`,I.className="uptime-chip",x>=99.9?I.classList.add("excellent"):x>=99?I.classList.add("good"):x>=95?I.classList.add("degraded"):I.classList.add("poor"),k&&(k.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}async function N(){try{const L=await(await fetch("/api/v1/updates/available")).json();if(!L.success||(document.querySelectorAll(".update-available-badge").forEach(H=>H.classList.remove("visible")),!L.updates?.length))return;for(const H of L.updates){const g=window.APPS||[];for(const I of g)if(I.containerId===H.containerId||I.id===H.containerName||I.name===H.containerName){if(E.has(I.id))break;const k=document.getElementById("update-badge-"+I.id);k&&(k.classList.add("visible"),k.title=`Image digest changed. Click to dismiss if already up to date.
|
|
${H.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k.classList.remove("visible"),E.add(I.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function S(){setTimeout(()=>{b(),N()},5e3),setInterval(()=>{b(),N()},6e4)}const T=window.refreshAll;T&&(window.refreshAll=async function(){try{await T(),setTimeout(b,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),S()})(),(function(){var b=null,E=null,N={},S={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},T=[["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 L=document.getElementById("theme-label");function H(t){if(S[t])return S[t];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[t]&&i[t].name||t}function g(){L&&(L.textContent=H(window.getActiveTheme()))}P.addEventListener("click",function(){var t=window.THEMES.slice(),i=window.getActiveTheme(),o=t.indexOf(i),d=t[(o+1)%t.length];window.applyTheme(d),g()}),g();function I(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},i={};T.forEach(function(d){i[d[2]]||(i[d[2]]=[]),i[d[2]].push(d)});var o="";return Object.keys(t).forEach(function(d){d==="advanced"?(o+='<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>',o+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):o+='<div class="theme-builder-section">',o+='<div class="theme-builder-section-title">'+t[d]+"</div>",(i[d]||[]).forEach(function(l){o+='<div class="theme-builder-row"><span class="theme-builder-label">'+l[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+l[0]+'"'+(d==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+l[0]+'">#000000</span></div>'}),o+="</div>"}),o}function k(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+H(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;">'+k()+'</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>'+I()+'<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 $=document.getElementById("theme-builder-modal"),C=document.getElementById("theme-builder-start"),R=document.getElementById("theme-builder-name"),M=document.getElementById("theme-builder-edit-select"),j=document.getElementById("theme-builder-existing"),B=document.getElementById("theme-builder-lightbg"),A=$.querySelectorAll(".theme-builder-color"),w=document.getElementById("theme-builder-advanced"),z=document.getElementById("theme-builder-advanced-toggle"),f=document.getElementById("theme-builder-export"),p=!1;z.addEventListener("click",function(){p=!p,w.style.display=p?"":"none",z.innerHTML=p?"Hide advanced colors ▲":"Show advanced colors ▼"});function y(){var t={};return A.forEach(function(i){t[i.dataset.prop]=i.value}),t["card-bg"]=t["card-base"],t}function v(){var t={};return A.forEach(function(i){i.dataset.advanced||(t[i.dataset.prop]=i.value)}),t["card-bg"]=t["card-base"],B.checked&&(t.lightBg=!0),t}function m(){var t=v(),i=window.deriveExtendedColors?window.deriveExtendedColors(t):{};A.forEach(function(o){if(o.dataset.advanced&&!N[o.dataset.prop]){var d=i[o.dataset.prop]||"#333333";o.value=d;var l=$.querySelector('[data-hex="'+o.dataset.prop+'"]');l&&(l.textContent=d.toUpperCase())}})}function r(t){var i=window.THEME_COLORS[t];i&&(N={},A.forEach(function(o){var d=i[o.dataset.prop]||"#000000";(d.startsWith("rgba")||d.startsWith("color-mix"))&&(d="#333333"),o.value=d;var l=$.querySelector('[data-hex="'+o.dataset.prop+'"]');l&&(l.textContent=d.toUpperCase())}),B.checked=!!i.lightBg,s())}function c(t){var i=safeGetJSON(window.USER_THEMES_KEY,{}),o=i[t];o&&(N={},A.forEach(function(d){var l=o[d.dataset.prop]||"#000000";d.value=l;var D=$.querySelector('[data-hex="'+d.dataset.prop+'"]');D&&(D.textContent=l.toUpperCase()),d.dataset.advanced&&o[d.dataset.prop]&&(N[d.dataset.prop]=!0)}),R.value=o.name||"",B.checked=!!o.lightBg,m(),s())}function s(){var t=y(),i=document.getElementById("theme-builder-preview"),o=document.getElementById("theme-preview-card"),d=o.querySelector(".preview-title"),l=o.querySelector(".preview-muted"),D=document.getElementById("preview-badge-ok"),O=document.getElementById("preview-badge-bad"),F=document.getElementById("preview-dot-ok"),q=document.getElementById("preview-dot-bad"),U=document.getElementById("preview-accent-btn"),G=o.querySelector(".preview-dots");i.style.background=t.bg,o.style.background=t["card-base"],o.style.borderColor=t.border,d.style.color=t.fg,l.style.color=t.muted,D.style.background=t["ok-bg"],D.style.color=t["ok-fg"],O.style.background=t["bad-bg"],O.style.color=t["bad-fg"],F.style.background=t["dot-ok"],q.style.background=t["dot-bad"],G.style.color=t.fg,U.style.background=t.accent,U.style.color=t.bg}function h(t){var i=window.deriveExtendedColors?window.deriveExtendedColors(t):{};window.THEME_PROPS.forEach(function(o){var d=t[o]||i[o];d&&document.documentElement.style.setProperty("--"+o,d)})}A.forEach(function(t){t.addEventListener("input",function(){var i=$.querySelector('[data-hex="'+t.dataset.prop+'"]');i&&(i.textContent=t.value.toUpperCase()),t.dataset.advanced?N[t.dataset.prop]=!0:m(),s(),h(y())})}),B.addEventListener("change",function(){m(),s(),h(y())}),C.addEventListener("change",function(){N={},r(C.value),h(y())});function u(){var t=safeGetJSON(window.USER_THEMES_KEY,{}),i=Object.keys(t);M.innerHTML='<option value="">\u2014 New Theme \u2014</option>',i.forEach(function(o){var d=document.createElement("option");d.value=o,d.textContent=t[o].name||o,M.appendChild(d)}),j.style.display=i.length?"":"none"}function a(){C.innerHTML=k()}M.addEventListener("change",function(){var t=this.value;t?(E=t,c(t),h(y())):(E=null,N={},R.value="",C.value=window.getActiveTheme(),r(C.value)),f.style.display=E?"":"none"});function e(){b=window.getActiveTheme(),E=null,N={},p=!1,w.style.display="none",z.innerHTML="Show advanced colors ▼",u(),a();var t=safeGetJSON(window.USER_THEMES_KEY,{});t[b]?(M.value=b,E=b,c(b)):(M.value="",R.value="",C.value=b,r(b)),f.style.display=E?"":"none",$.classList.add("show")}var n=document.getElementById("theme-customize-btn");n&&n.addEventListener("click",function(){e()}),document.getElementById("theme-builder-save").addEventListener("click",function(){var t=y(),i=R.value.trim();if(!i){showNotification("Please enter a theme name","warning",3e3),R.focus();return}var o=safeGetJSON(window.USER_THEMES_KEY,{}),d,l=null;if(E){d=E;var D=window.slugifyThemeName(i,E);if(D!==E){l=E,delete o[E];var O=window.THEMES.indexOf(E);O!==-1&&window.THEMES.splice(O,1),delete window.THEME_COLORS[E],d=D}}else d=window.slugifyThemeName(i);var F={name:i};B.checked&&(F.lightBg=!0),window.THEME_PROPS.forEach(function(U){t[U]&&(F[U]=t[U])}),o[d]=F,safeSet(window.USER_THEMES_KEY,JSON.stringify(o)),window.clearCustomProperties(),window.injectUserThemeStyles(),window.applyTheme(d),$.classList.remove("show"),g();var q={};window.THEME_PROPS.forEach(function(U){t[U]&&(q[U]=t[U])}),l&&secureFetch("/api/v1/themes/"+l,{method:"DELETE"}).catch(function(){}),secureFetch("/api/v1/themes/"+d,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:i,colors:q,lightBg:B.checked})}).then(function(){showNotification(i+" theme saved","success",3e3)}).catch(function(){showNotification(i+" theme saved locally (server sync failed)","warning",3e3)})}),document.getElementById("theme-builder-cancel").addEventListener("click",function(){$.classList.remove("show"),window.clearCustomProperties(),b&&window.applyTheme(b)}),document.getElementById("theme-builder-delete").addEventListener("click",function(){if(E){var t=safeGetJSON(window.USER_THEMES_KEY,{}),i=t[E]?t[E].name:E;if(confirm('Delete "'+i+'" theme?')){var o=E;delete t[o],safeSet(window.USER_THEMES_KEY,JSON.stringify(t));var d=window.THEMES.indexOf(o);d!==-1&&window.THEMES.splice(d,1),delete window.THEME_COLORS[o],window.clearCustomProperties(),window.injectUserThemeStyles();var l=b&&b!==o?b:"dark";window.applyTheme(l),E=null,$.classList.remove("show"),g(),secureFetch("/api/v1/themes/"+o,{method:"DELETE"}).then(function(){showNotification(i+" theme deleted","success",3e3)}).catch(function(){showNotification(i+" theme deleted locally (server sync failed)","warning",3e3)})}}}),document.getElementById("theme-builder-export").addEventListener("click",function(){if(!E){showNotification("Save the theme first, then export","warning",3e3);return}var t=safeGetJSON(window.USER_THEMES_KEY,{}),i=t[E];if(i){var o={_dashcaddy_theme:!0,version:"1.0",exportDate:new Date().toISOString(),slug:E,name:i.name,lightBg:i.lightBg||!1,colors:{}};window.THEME_PROPS.forEach(function(O){i[O]&&(o.colors[O]=i[O])});var d=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),l=URL.createObjectURL(d),D=document.createElement("a");D.href=l,D.download=E+"-theme.json",document.body.appendChild(D),D.click(),document.body.removeChild(D),URL.revokeObjectURL(l),showNotification("Theme exported as "+E+"-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(i){var o=i.target.files[0];if(o){var d=new FileReader;d.onload=function(l){try{var D=JSON.parse(l.target.result),O,F,q;if(D._dashcaddy_theme&&D.colors)O=D.name||"Imported",F=D.colors,q=D.lightBg||!1;else if(D.name&&(D.bg||D["card-base"]))O=D.name,F={},window.THEME_PROPS.forEach(function(U){D[U]&&(F[U]=D[U])}),q=D.lightBg||!1;else throw new Error("Not a valid DashCaddy theme file");R.value=O,B.checked=!!q,E=null,M.value="",N={},A.forEach(function(U){var G=F[U.dataset.prop]||"#000000";U.value=G;var W=$.querySelector('[data-hex="'+U.dataset.prop+'"]');W&&(W.textContent=G.toUpperCase()),U.dataset.advanced&&F[U.dataset.prop]&&(N[U.dataset.prop]=!0)}),m(),s(),h(y()),f.style.display="none",showNotification('"'+O+'" loaded into builder. Click Save to keep it.',"success",5e3)}catch(U){showNotification("Import failed: "+U.message,"error",5e3)}},d.readAsText(o)}},t.click()}),wireModal($),$.addEventListener("click",function(t){t.target===$&&(window.clearCustomProperties(),b&&window.applyTheme(b))})})(),(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 b=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),N=document.getElementById("license-activate"),S=document.getElementById("license-deactivate"),T=document.getElementById("license-error"),P=document.getElementById("license-success"),L=document.getElementById("license-badge-icon"),H=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),I=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let $=null;function C(){T.style.display="none",P.style.display="none"}function R(y){C(),T.textContent=y,T.style.display="block"}function M(y){C(),P.textContent=y,P.style.display="block"}function j(y){if($=y,y.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",L.textContent="\u2605",H.textContent="Premium Active";const r=y.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(y.expiresAt).toLocaleDateString()}</strong> (${y.daysRemaining} days remaining)</div>`;I.innerHTML=`
|
|
<div>Code: <code style="font-family: monospace;">${y.code||"***"}</code></div>
|
|
${r}
|
|
`,x.style.display="none",N.style.display="none",S.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",L.textContent="\u2606",H.textContent=y.expired?"License Expired":"Free Tier",I.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="",N.style.display="",S.style.display="none";const v=y.premiumFeatures||{},m=new Set(y.features||[]);k.innerHTML=Object.entries(v).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
|
<span style="font-size: 1.1rem;">${m.has(r)?"\u2705":"\u{1F512}"}</span>
|
|
<div>
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
|
|
</div>
|
|
</div>`).join("")}async function B(){try{const v=await(await fetch("/api/v1/license/status")).json();v.success&&(j(v.license),z(v.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function A(){const y=E.value.trim();if(!y){R("Please enter a license code.");return}C(),N.disabled=!0,N.textContent="Activating...";try{const m=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();m.success?(M(m.message),E.value="",j(m.license),showNotification("License activated! Premium features unlocked.","success",5e3),z(m.license)):R(m.error||"Activation failed")}catch(v){R("Network error: "+v.message)}finally{N.disabled=!1,N.textContent="Activate"}}async function w(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){S.disabled=!0,S.textContent="Deactivating...";try{const v=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();v.success?(M(v.message),await B(),showNotification("License deactivated.","info",3e3),z({active:!1})):R(v.error||"Deactivation failed")}catch(y){R("Network error: "+y.message)}finally{S.disabled=!1,S.textContent="Deactivate"}}}function z(y){const v=document.getElementById("license-status-topbar"),m=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(v)if(v.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(m.textContent="\u2605",r.textContent="PREMIUM",y.lifetime)c.textContent="\xB7 LIFETIME";else{const s=y.daysRemaining;c.textContent=s!=null?"\xB7 "+s+"d remaining":""}else m.textContent="\u2606",r.textContent=y.expired?"EXPIRED":"FREE TIER",c.textContent=""}function f(){C(),B(),b.classList.add("show")}E.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 v=["DC"],m=y.substring(2);for(let r=0;r<m.length;r+=5)v.push(m.substring(r,r+5));y=v.join("-")}this._prevLength=y.length,this.value=y}),N.addEventListener("click",A),S.addEventListener("click",w),E.addEventListener("keydown",y=>{y.key==="Enter"&&A()}),wireModal(b,document.getElementById("license-cancel"));const p=document.getElementById("license-status-topbar");p&&p.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=f,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},B().then(y=>{$&&z($)})})();
|