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:
Krystie
2026-05-15 01:27:56 -07:00
parent 22c48076c0
commit c50b106faf
7 changed files with 801 additions and 339 deletions
+1
View File
@@ -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'),
+88 -88
View File
File diff suppressed because one or more lines are too long
+264 -207
View File
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -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}})();
+36 -36
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -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>
+403
View File
@@ -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);
})();