Add Container Log Viewer UI with streaming, search, and download
- New container-logs.js module for viewing Docker container logs
- Integrated with existing API endpoints (/logs/containers, /logs/container/:id, /logs/stream/:id)
- Features:
- Select container from dropdown
- View logs with stdout/stderr color coding
- Real-time log streaming via SSE
- Search/filter within logs
- Download logs as text file
- Line count and filter indicators
- Added '📜 Container Logs' button to Tools section in index.html
- Added to features.js bundle via build.js
- Rebuilt dist files
This commit is contained in:
@@ -33,6 +33,7 @@ const bundles = {
|
||||
JS('recipes.js'),
|
||||
JS('import-export.js'),
|
||||
JS('error-logs.js'),
|
||||
JS('container-logs.js'),
|
||||
JS('smart-arr-connect.js'),
|
||||
JS('notification-settings.js'),
|
||||
JS('panel-tabs.js'),
|
||||
|
||||
Vendored
+88
-88
File diff suppressed because one or more lines are too long
Vendored
+264
-207
File diff suppressed because one or more lines are too long
Vendored
+7
-7
@@ -1,4 +1,4 @@
|
||||
(function(){function k(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}k();function n(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let l=!1;async function m(){if(l){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(l=!0,await window.loadServices(),window.buildGrid(),n(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}p(),q()&&v()}function v(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function q(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function g(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",S=>{S.preventDefault();const y=i.classList.toggle("open");h.setAttribute("aria-expanded",y?"true":"false");const w={};document.querySelectorAll(".tools-section").forEach(f=>{w[f.dataset.section]=f.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(w))}))})}g();function p(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{v();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=m,window.loadCustomServices=k,u(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}m()})()})(),(function(){"use strict";const k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let n=null,u=null,l=null;function m(){try{v(),document.addEventListener("keydown",q),console.log("[Keyboard Shortcuts] Initialized"),console.log("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),console.log("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function v(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=`
|
||||
(function(){function p(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}p();function y(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.classList.add("loaded"))})}function n(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let u=!1;async function l(){if(u){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(u=!0,await window.loadServices(),window.buildGrid(),y(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}k(),w()&&m()}function m(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function w(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function b(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",q=>{q.preventDefault();const S=i.classList.toggle("open");h.setAttribute("aria-expanded",S?"true":"false");const f={};document.querySelectorAll(".tools-section").forEach(v=>{f[v.dataset.section]=v.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(f))}))})}b();function k(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{m();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=l,window.loadCustomServices=p,n(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}l()})()})(),(function(){"use strict";const p=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},y=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let n=null,u=null,l=null;function m(){try{w(),document.addEventListener("keydown",b),p("[Keyboard Shortcuts] Initialized"),p("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),p("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function w(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=`
|
||||
<div class="quick-search-content">
|
||||
<div class="quick-search-input-wrapper">
|
||||
<span class="quick-search-icon">\u{1F50D}</span>
|
||||
@@ -160,7 +160,7 @@
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",r),u.addEventListener("keydown",w),n.addEventListener("click",e=>{e.target===n&&p()})}function q(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),g();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){p();return}a()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function g(){try{n.classList.add("show"),u.value="",u.focus(),c()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function p(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function a(){for(const t of k){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function c(){const t=`
|
||||
`,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",h),u.addEventListener("keydown",v),n.addEventListener("click",e=>{e.target===n&&a()})}function b(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),k();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){a();return}c()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function k(){try{n.classList.add("show"),u.value="",u.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function a(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function c(){for(const t of y){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function i(){const t=`
|
||||
<div class="quick-search-category">Quick Actions</div>
|
||||
<div class="quick-search-item" data-action="refresh">
|
||||
<span class="quick-search-item-icon">\u{1F504}</span>
|
||||
@@ -192,9 +192,9 @@
|
||||
</div>
|
||||
|
||||
<div class="quick-search-category">Services</div>
|
||||
${i()}
|
||||
`;l.innerHTML=t,y()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",b=s.dataset.app||"";d&&d!=="--"&&(e+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${b}">
|
||||
${r()}
|
||||
`;l.innerHTML=t,f()}function r(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",g=s.dataset.app||"";d&&d!=="--"&&(e+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${g}">
|
||||
<span class="quick-search-item-icon">${o==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${d}</div>
|
||||
@@ -202,7 +202,7 @@
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${o.toUpperCase()}</span>
|
||||
</div>
|
||||
`)}),e||'<div class="quick-search-empty">No services found</div>'}function r(t){try{const e=t.target.value.toLowerCase().trim();if(!e){c();return}const s=h(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function h(t){const e={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const b=o.querySelector(".name")?.textContent||"",x=o.dataset.app||"",E=o.dataset.status||"unknown";(b.toLowerCase().includes(t)||x.toLowerCase().includes(t))&&e.services.push({id:x,title:b,status:E,icon:E==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=`
|
||||
`)}),e||'<div class="quick-search-empty">No services found</div>'}function h(t){try{const e=t.target.value.toLowerCase().trim();if(!e){i();return}const s=q(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function q(t){const e={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const g=o.querySelector(".name")?.textContent||"",E=o.dataset.app||"",C=o.dataset.status||"unknown";(g.toLowerCase().includes(t)||E.toLowerCase().includes(t))&&e.services.push({id:E,title:g,status:C,icon:C==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=`
|
||||
<div class="quick-search-item" data-action="${s.id}">
|
||||
<span class="quick-search-item-icon">${s.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
@@ -217,4 +217,4 @@
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${s.status.toUpperCase()}</span>
|
||||
</div>
|
||||
`})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,y()}function y(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>f(e)),s===0&&e.classList.add("selected")})}function w(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&f(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function f(t){try{const e=t.dataset.action,s=t.dataset.service;switch(p(),e){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(s){const d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:console.log("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:g,closeQuickSearch:p}})();
|
||||
`})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,f()}function f(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>x(e)),s===0&&e.classList.add("selected")})}function v(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&x(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function x(t){try{const e=t.dataset.action,s=t.dataset.service;switch(a(),e){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(s){const d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:p("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:k,closeQuickSearch:a}})();
|
||||
|
||||
Vendored
+36
-36
File diff suppressed because one or more lines are too long
+2
-1
@@ -148,7 +148,8 @@
|
||||
<span class="tools-section-label">Tools</span>
|
||||
</button>
|
||||
<div class="tools-section-items">
|
||||
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button>
|
||||
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
|
||||
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
// ========== CONTAINER LOG VIEWER ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
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;">📜 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;">🔄 Refresh</button>
|
||||
<button id="cl-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">▶ Stream</button>
|
||||
<button id="cl-download" style="padding: 6px 14px; font-size: 0.85rem;">💾 Download</button>
|
||||
<button id="cl-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">✕</button>
|
||||
<button id="cl-close" class="close-btn" style="padding: 6px 10px;">✕</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">🔴</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 modal = document.getElementById('container-logs-modal');
|
||||
const containerSelect = document.getElementById('cl-container-select');
|
||||
const logContent = document.getElementById('cl-log-content');
|
||||
const logSearch = document.getElementById('cl-log-search');
|
||||
const logTail = document.getElementById('cl-log-tail');
|
||||
const refreshBtn = document.getElementById('cl-refresh');
|
||||
const streamBtn = document.getElementById('cl-stream');
|
||||
const downloadBtn = document.getElementById('cl-download');
|
||||
const clearSearchBtn = document.getElementById('cl-clear-search');
|
||||
const closeBtn = document.getElementById('cl-close');
|
||||
const closeBtn2 = document.getElementById('cl-close-btn');
|
||||
const streamStatus = document.getElementById('cl-stream-status');
|
||||
const streamIndicator = document.getElementById('cl-stream-indicator');
|
||||
const streamText = document.getElementById('cl-stream-text');
|
||||
const lineCount = document.getElementById('cl-line-count');
|
||||
const filterCount = document.getElementById('cl-filter-count');
|
||||
|
||||
// Container info elements
|
||||
const imageEl = document.getElementById('cl-image');
|
||||
const statusEl = document.getElementById('cl-status');
|
||||
const createdEl = document.getElementById('cl-created');
|
||||
|
||||
let currentContainerId = null;
|
||||
let currentLogs = [];
|
||||
let filteredLogs = [];
|
||||
let eventSource = null;
|
||||
let isStreaming = false;
|
||||
let searchTimeout = null;
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Format log entry for display
|
||||
function formatLogEntry(log, index) {
|
||||
const streamClass = log.stream === 'stderr' ? 'log-stderr' : 'log-stdout';
|
||||
const streamIcon = log.stream === 'stderr' ? '⚠️' : '📤';
|
||||
return `
|
||||
<div class="log-entry ${streamClass}" data-index="${index}" 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;">${index + 1}</span>
|
||||
<span class="log-stream-icon" style="color: ${log.stream === 'stderr' ? '#f59e0b' : '#22c55e'};">${streamIcon}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${escapeHtml(log.text)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render logs to the content area
|
||||
function renderLogs(logs, searchTerm = '') {
|
||||
if (!logs || logs.length === 0) {
|
||||
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>';
|
||||
lineCount.textContent = '0 lines';
|
||||
filterCount.textContent = '0 filtered';
|
||||
return;
|
||||
}
|
||||
|
||||
currentLogs = logs;
|
||||
filteredLogs = searchTerm ? logs.filter(log =>
|
||||
log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : logs;
|
||||
|
||||
lineCount.textContent = `${logs.length} lines`;
|
||||
filterCount.textContent = searchTerm ? `${filteredLogs.length} of ${logs.length} shown` : `${logs.length} shown`;
|
||||
|
||||
if (filteredLogs.length === 0) {
|
||||
logContent.innerHTML = `<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${escapeHtml(searchTerm)}"</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
logContent.innerHTML = filteredLogs.map((log, i) => formatLogEntry(log, i)).join('');
|
||||
|
||||
// Scroll to bottom
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
|
||||
// Load container list
|
||||
async function loadContainers() {
|
||||
try {
|
||||
const data = await getJSON('/api/v1/logs/containers');
|
||||
const containers = data.containers || [];
|
||||
|
||||
// Store current selection
|
||||
const currentVal = containerSelect.value;
|
||||
|
||||
containerSelect.innerHTML = '<option value="">Select a container...</option>';
|
||||
|
||||
containers.forEach(c => {
|
||||
const option = document.createElement('option');
|
||||
option.value = c.id;
|
||||
option.textContent = `${c.name} (${c.image.split(':')[0]}) - ${c.status}`;
|
||||
option.dataset.name = c.name;
|
||||
option.dataset.image = c.image;
|
||||
option.dataset.status = c.status;
|
||||
option.dataset.created = c.created;
|
||||
containerSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Restore selection if still valid
|
||||
if (currentVal && containerSelect.querySelector(`option[value="${currentVal}"]`)) {
|
||||
containerSelect.value = currentVal;
|
||||
loadContainerInfo(currentVal);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load containers:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load container info
|
||||
function loadContainerInfo(containerId) {
|
||||
const option = containerSelect.querySelector(`option[value="${containerId}"]`);
|
||||
if (option) {
|
||||
imageEl.textContent = option.dataset.image || '-';
|
||||
statusEl.textContent = option.dataset.status || '-';
|
||||
statusEl.style.color = option.dataset.status === 'running' ? 'var(--ok-fg, #4ade80)' : 'var(--bad-fg, #ef4444)';
|
||||
createdEl.textContent = formatDate(option.dataset.created);
|
||||
}
|
||||
}
|
||||
|
||||
// Load logs for selected container
|
||||
async function loadLogs() {
|
||||
const containerId = containerSelect.value;
|
||||
if (!containerId) {
|
||||
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any existing stream
|
||||
stopStream();
|
||||
|
||||
currentContainerId = containerId;
|
||||
loadContainerInfo(containerId);
|
||||
|
||||
const tail = logTail.value;
|
||||
const searchTerm = logSearch.value.trim();
|
||||
|
||||
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';
|
||||
|
||||
try {
|
||||
const url = `/api/v1/logs/container/${containerId}${tail !== 'all' ? `?tail=${tail}` : ''}`;
|
||||
const data = await getJSON(url);
|
||||
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
renderLogs(data.logs, searchTerm);
|
||||
} else {
|
||||
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>';
|
||||
lineCount.textContent = '0 lines';
|
||||
filterCount.textContent = '0 filtered';
|
||||
}
|
||||
} catch (err) {
|
||||
logContent.innerHTML = `<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Start streaming logs
|
||||
function startStream() {
|
||||
const containerId = containerSelect.value;
|
||||
if (!containerId) return;
|
||||
|
||||
// Stop any existing stream
|
||||
stopStream();
|
||||
|
||||
isStreaming = true;
|
||||
streamBtn.textContent = '⏹ Stop';
|
||||
streamStatus.style.display = 'flex';
|
||||
streamIndicator.textContent = '🟢';
|
||||
streamText.textContent = 'Connecting...';
|
||||
|
||||
const url = `/api/v1/logs/stream/${containerId}`;
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
streamIndicator.textContent = '🟢';
|
||||
streamText.textContent = 'Connected - streaming logs';
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const log = JSON.parse(event.data);
|
||||
|
||||
if (log.error) {
|
||||
streamIndicator.textContent = '🔴';
|
||||
streamText.textContent = `Error: ${log.error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to current logs
|
||||
currentLogs.push(log);
|
||||
filteredLogs.push(log);
|
||||
|
||||
// Update counts
|
||||
lineCount.textContent = `${currentLogs.length} lines`;
|
||||
filterCount.textContent = `${filteredLogs.length} shown`;
|
||||
|
||||
// Append new log entry
|
||||
const searchTerm = logSearch.value.trim();
|
||||
if (!searchTerm || (log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase()))) {
|
||||
const entry = document.createElement('div');
|
||||
entry.innerHTML = formatLogEntry(log, filteredLogs.length - 1);
|
||||
const entryDiv = entry.firstElementChild;
|
||||
entryDiv.style.background = '#1a3a1a';
|
||||
logContent.appendChild(entryDiv);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing log:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
streamIndicator.textContent = '🔴';
|
||||
streamText.textContent = 'Disconnected';
|
||||
isStreaming = false;
|
||||
streamBtn.textContent = '▶ Stream';
|
||||
};
|
||||
|
||||
// Store the EventSource for cleanup
|
||||
modal._eventSource = eventSource;
|
||||
}
|
||||
|
||||
// Stop streaming logs
|
||||
function stopStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (modal._eventSource) {
|
||||
modal._eventSource.close();
|
||||
modal._eventSource = null;
|
||||
}
|
||||
isStreaming = false;
|
||||
streamBtn.textContent = '▶ Stream';
|
||||
streamStatus.style.display = 'none';
|
||||
}
|
||||
|
||||
// Download logs as file
|
||||
function downloadLogs() {
|
||||
if (!currentLogs || currentLogs.length === 0) {
|
||||
showNotification('No logs to download', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerName = containerSelect.querySelector(`option[value="${currentContainerId}"]`)?.dataset.name || currentContainerId;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${containerName}-logs-${timestamp}.txt`;
|
||||
|
||||
const content = currentLogs.map(log => {
|
||||
const timestamp = log.timestamp || '';
|
||||
const stream = log.stream === 'stderr' ? '[ERR]' : '[OUT]';
|
||||
return `${timestamp ? timestamp + ' ' : ''}${stream} ${log.text}`;
|
||||
}).join('\n');
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showNotification(`Downloaded ${currentLogs.length} log lines`, 'success');
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
containerSelect?.addEventListener('change', () => {
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
logTail?.addEventListener('change', () => {
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener('click', () => {
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
streamBtn?.addEventListener('click', () => {
|
||||
if (isStreaming) {
|
||||
stopStream();
|
||||
} else {
|
||||
startStream();
|
||||
}
|
||||
});
|
||||
|
||||
downloadBtn?.addEventListener('click', () => {
|
||||
downloadLogs();
|
||||
});
|
||||
|
||||
clearSearchBtn?.addEventListener('click', () => {
|
||||
logSearch.value = '';
|
||||
renderLogs(currentLogs, '');
|
||||
});
|
||||
|
||||
logSearch?.addEventListener('input', () => {
|
||||
// Debounce search
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
renderLogs(currentLogs, logSearch.value.trim());
|
||||
}, 300);
|
||||
});
|
||||
|
||||
logSearch?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
logSearch.value = '';
|
||||
renderLogs(currentLogs, '');
|
||||
}
|
||||
});
|
||||
|
||||
// Open modal
|
||||
const openBtn = document.getElementById('view-container-logs');
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
loadContainers();
|
||||
});
|
||||
|
||||
// Close modal handlers
|
||||
function closeModal() {
|
||||
stopStream();
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
closeBtn2?.addEventListener('click', closeModal);
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Wire modal (close on backdrop click)
|
||||
wireModal(modal, null, closeModal);
|
||||
})();
|
||||
Reference in New Issue
Block a user