1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-21 21:33:18 +01:00

feat: enables filters and level filters when downloading (#4251)

This commit is contained in:
Amir Raminfar
2025-11-19 14:35:30 -08:00
committed by GitHub
parent 80df836f1d
commit bafcbf6d66
22 changed files with 182 additions and 59 deletions

View File

@@ -218,6 +218,7 @@ declare global {
const useDevicesList: typeof import('@vueuse/core')['useDevicesList'] const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia'] const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility'] const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDownloadUrl: typeof import('./composable/downloadUrl')['useDownloadUrl']
const useDraggable: typeof import('@vueuse/core')['useDraggable'] const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDrawer: typeof import('./composable/drawer')['useDrawer'] const useDrawer: typeof import('./composable/drawer')['useDrawer']
const useDropZone: typeof import('@vueuse/core')['useDropZone'] const useDropZone: typeof import('@vueuse/core')['useDropZone']
@@ -616,6 +617,7 @@ declare module 'vue' {
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']> readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']> readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']> readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
readonly useDownloadUrl: UnwrapRef<typeof import('./composable/downloadUrl')['useDownloadUrl']>
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']> readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
readonly useDrawer: UnwrapRef<typeof import('./composable/drawer')['useDrawer']> readonly useDrawer: UnwrapRef<typeof import('./composable/drawer')['useDrawer']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']> readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>

View File

@@ -16,7 +16,10 @@
</a> </a>
</li> </li>
<li v-if="enableDownload"> <li v-if="enableDownload">
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a> <a :href="downloadUrl" download>
<octicon:download-24 />
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
</a>
</li> </li>
<li v-if="!historical"> <li v-if="!historical">
<a @click="showSearch = true"> <a @click="showSearch = true">
@@ -199,17 +202,8 @@ if (enableShell) {
}); });
} }
const downloadParams = computed(() => const containerRef = computed(() => [container]);
Object.entries(toValue(streamConfig)) const { downloadUrl, isFiltered } = useDownloadUrl(containerRef, streamConfig, levels);
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
);
const downloadUrl = computed(() =>
withBase(
`/api/containers/${container.host}~${container.id}/download?${new URLSearchParams(downloadParams.value).toString()}`,
),
);
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart); const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);

View File

@@ -16,7 +16,10 @@
</a> </a>
</li> </li>
<li v-if="enableDownload"> <li v-if="enableDownload">
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a> <a :href="downloadUrl" download>
<octicon:download-24 />
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
</a>
</li> </li>
<li> <li>
<a @click="showSearch = true"> <a @click="showSearch = true">
@@ -91,19 +94,9 @@ const { showSearch } = useSearchFilter();
const { enableDownload } = config; const { enableDownload } = config;
const clear = defineEmit(); const clear = defineEmit();
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext(); const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext();
const downloadParams = computed(() => const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels);
Object.entries(toValue(streamConfig))
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
);
const downloadUrl = computed(() =>
withBase(
`/api/containers/${containers.value.map((c) => c.host + "~" + c.id).join(",")}/download?${new URLSearchParams(downloadParams.value).toString()}`,
),
);
const hideMenu = (e: MouseEvent) => { const hideMenu = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) { if (e.target instanceof HTMLAnchorElement) {

View File

@@ -0,0 +1,45 @@
import { Container } from "@/models/Container";
import { allLevels } from "@/composable/logContext";
export function useDownloadUrl(
containers: Ref<Container[]> | ComputedRef<Container[]>,
streamConfig: { stdout: boolean; stderr: boolean } | Ref<{ stdout: boolean; stderr: boolean }>,
levels: Ref<Set<string>>,
) {
const { debouncedSearchFilter } = useSearchFilter();
const downloadUrl = computed(() => {
const params = new URLSearchParams();
const config = toValue(streamConfig);
// Add stdout/stderr
if (config.stdout) params.append("stdout", "1");
if (config.stderr) params.append("stderr", "1");
// Add filter if search is active
if (debouncedSearchFilter.value) {
params.append("filter", debouncedSearchFilter.value);
}
// Add levels (multiple values) only if filtered
const selectedLevels = Array.from(levels.value);
if (selectedLevels.length > 0 && selectedLevels.length < allLevels.length) {
selectedLevels.forEach((level) => params.append("levels", level));
}
const containerIds = toValue(containers)
.map((c) => c.host + "~" + c.id)
.join(",");
return withBase(`/api/containers/${containerIds}/download?${params.toString()}`);
});
const isFiltered = computed(
() => debouncedSearchFilter.value || (levels.value.size > 0 && levels.value.size < allLevels.length),
);
return {
downloadUrl,
isFiltered,
};
}

View File

@@ -5,11 +5,14 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
"github.com/amir20/dozzle/internal/auth" "github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/container" "github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
support_web "github.com/amir20/dozzle/internal/support/web"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -54,15 +57,34 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
return return
} }
// Set headers for zip file // Parse filter regex if provided
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=container-logs-%s.zip", nowFmt)) var regex *regexp.Regexp
w.Header().Set("Content-Type", "application/zip") var err error
if r.URL.Query().Has("filter") {
regex, err = support_web.ParseRegex(r.URL.Query().Get("filter"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// Create zip writer // Parse level filters if provided
zw := zip.NewWriter(w) levels := make(map[string]struct{})
defer zw.Close() if r.URL.Query().Has("levels") {
for _, level := range r.URL.Query()["levels"] {
levels[level] = struct{}{}
}
}
// Validate all containers before starting to write response
type containerInfo struct {
hostId string
host string
id string
containerService *container_support.ContainerService
}
containers := make([]containerInfo, 0, len(hostIds))
// Process each container
for _, hostId := range hostIds { for _, hostId := range hostIds {
parts := strings.Split(hostId, "~") parts := strings.Split(hostId, "~")
if len(parts) != 2 { if len(parts) != 2 {
@@ -80,29 +102,79 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
return return
} }
containers = append(containers, containerInfo{
hostId: hostId,
host: host,
id: id,
containerService: containerService,
})
}
// Set headers for zip file
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=container-logs-%s.zip", nowFmt))
w.Header().Set("Content-Type", "application/zip")
// Create zip writer
zw := zip.NewWriter(w)
defer zw.Close()
// Process each container - errors after this point are logged only since response has started
for _, c := range containers {
// Create new file in zip for this container's logs // Create new file in zip for this container's logs
fileName := fmt.Sprintf("%s-%s.log", containerService.Container.Name, nowFmt) fileName := fmt.Sprintf("%s-%s.log", c.containerService.Container.Name, nowFmt)
f, err := zw.Create(fileName) f, err := zw.Create(fileName)
if err != nil { if err != nil {
log.Error().Err(err).Msgf("error creating zip entry for container %s", id) log.Error().Err(err).Msgf("error creating zip entry for container %s", c.id)
http.Error(w, fmt.Sprintf("error creating zip entry: %v", err), http.StatusInternalServerError)
return return
} }
// Get container logs // Get container logs - use LogsBetweenDates if filtering is needed, otherwise use RawLogs
reader, err := containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes) if regex != nil || len(levels) > 0 {
// Fetch parsed log events for filtering
events, err := c.containerService.LogsBetweenDates(r.Context(), time.Time{}, now, stdTypes)
if err != nil { if err != nil {
log.Error().Err(err).Msgf("error getting logs for container %s", id) log.Error().Err(err).Msgf("error getting logs for container %s", c.id)
http.Error(w, fmt.Sprintf("error getting logs for container %s: %v", id, err), http.StatusInternalServerError) return
}
// Filter and write events
for event := range events {
// Apply regex filter if provided
if regex != nil && !support_web.Search(regex, event) {
continue
}
// Apply level filter if provided
if len(levels) > 0 {
if _, ok := levels[event.Level]; !ok {
continue
}
}
// Format timestamp in UTC
timestamp := time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano)
// Write timestamp followed by message
_, err = fmt.Fprintf(f, "%s %s\n", timestamp, event.RawMessage)
if err != nil {
log.Error().Err(err).Msgf("error writing log for container %s", c.id)
return
}
}
} else {
// No filtering needed, use raw logs for better performance
reader, err := c.containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
if err != nil {
log.Error().Err(err).Msgf("error getting logs for container %s", c.id)
return return
} }
// Copy logs to zip file // Copy logs to zip file
_, err = io.Copy(f, reader) _, err = io.Copy(f, reader)
if err != nil { if err != nil {
log.Error().Err(err).Msgf("error copying logs for container %s", id) log.Error().Err(err).Msgf("error copying logs for container %s", c.id)
http.Error(w, fmt.Sprintf("error copying logs for container %s: %v", id, err), http.StatusInternalServerError)
return return
} }
} }
} }
}

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Ryd clear: Ryd
download: Download download: Download
download-filtered: Download Filtrerede Logs
search: Søg search: Søg
show: Vis kun {std} show: Vis kun {std}
show-all: Vis alle streams show-all: Vis alle streams

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Leeren clear: Leeren
download: Herunterladen download: Herunterladen
download-filtered: Gefilterte Logs Herunterladen
search: Suchen search: Suchen
show: Zeige nur {std} show: Zeige nur {std}
show-all: Zeige alle Streams show-all: Zeige alle Streams

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Clear clear: Clear
download: Download download: Download
download-filtered: Download Filtered Logs
search: Search search: Search
show: Show {std} show: Show {std}
show-all: Show all show-all: Show all

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Limpiar clear: Limpiar
download: Descargar download: Descargar
download-filtered: Descargar Logs Filtrados
search: Buscar search: Buscar
show: Mostrar {std} show: Mostrar {std}
show-all: Mostrar todo show-all: Mostrar todo

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Effacer clear: Effacer
download: Téléchargement download: Téléchargement
download-filtered: Télécharger Logs Filtrés
search: Chercher search: Chercher
show: Montrer seulement {std} show: Montrer seulement {std}
show-all: Afficher tous les flux show-all: Afficher tous les flux

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Bersihkan clear: Bersihkan
download: Unduh download: Unduh
download-filtered: Unduh Log yang Difilter
search: Cari search: Cari
show: Tampilkan {std} show: Tampilkan {std}
show-all: Tampilkan semua show-all: Tampilkan semua

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Pulisci clear: Pulisci
download: Scarica download: Scarica
download-filtered: Scarica Log Filtrati
search: Cerca search: Cerca
show: Mostra solo {std} show: Mostra solo {std}
show-all: Mostra tutto show-all: Mostra tutto

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: 지우기 clear: 지우기
download: 다운로드 download: 다운로드
download-filtered: 필터링된 로그 다운로드
search: 검색 search: 검색
show: "{std} 보기" show: "{std} 보기"
show-all: 전체 보기 show-all: 전체 보기

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Wissen clear: Wissen
download: Downloaden download: Downloaden
download-filtered: Gefilterde Logs Downloaden
search: Zoeken search: Zoeken
show: Toon {std} show: Toon {std}
show-all: Toon alles show-all: Toon alles

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Wyczyść clear: Wyczyść
download: Pobierz download: Pobierz
download-filtered: Pobierz Filtrowane Logi
search: Szukaj search: Szukaj
show: Pokaż tylko {std} show: Pokaż tylko {std}
show-all: Pokaż wszystko show-all: Pokaż wszystko

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Limpar clear: Limpar
download: Descarregar download: Descarregar
download-filtered: Descarregar Logs Filtrados
search: Pesquisa search: Pesquisa
show: Mostrar apenas {std} show: Mostrar apenas {std}
show-all: Mostrar todos os fluxos show-all: Mostrar todos os fluxos

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Limpar clear: Limpar
download: Baixar download: Baixar
download-filtered: Baixar Logs Filtrados
search: Pesquisar search: Pesquisar
show: Mostrar {std} show: Mostrar {std}
show-all: Mostrar tudo show-all: Mostrar tudo

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Очистить clear: Очистить
download: Скачать download: Скачать
download-filtered: Скачать Отфильтрованные Логи
search: Поиск search: Поиск
show: Показать только {std} show: Показать только {std}
show-all: Показать все потоки show-all: Показать все потоки

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Počisti clear: Počisti
download: Prenesi download: Prenesi
download-filtered: Prenesi Filtrirane Dnevnike
search: Iskanje search: Iskanje
show: Prikaži {std} show: Prikaži {std}
show-all: Prikaži vse show-all: Prikaži vse
@@ -121,8 +122,8 @@ releases:
features: ena nova funkcija | {count} funkcij features: ena nova funkcija | {count} funkcij
bugFixes: en popravek napake | {counr} popravkov bugFixes: en popravek napake | {counr} popravkov
breaking: ena ključna sprememba | {count} ključnih sprememb breaking: ena ključna sprememba | {count} ključnih sprememb
three_parts: '{first}, {second} in {third}' three_parts: "{first}, {second} in {third}"
two_parts: '{first} s/z {second}' two_parts: "{first} s/z {second}"
latest: Najnovejše latest: Najnovejše
no_releases: Imate najnovejšo različico no_releases: Imate najnovejšo različico
log_actions: log_actions:

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: Temizle clear: Temizle
download: İndir download: İndir
download-filtered: Filtrelenmiş Logları İndir
search: Ara search: Ara
show: Sadece {std} göster show: Sadece {std} göster
show-all: Tüm akışları göster show-all: Tüm akışları göster

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: 清除 clear: 清除
download: 下載 download: 下載
download-filtered: 下載篩選的日誌
search: 搜尋 search: 搜尋
show: 僅顯示 {std} show: 僅顯示 {std}
show-all: 顯示全部 show-all: 顯示全部

View File

@@ -1,6 +1,7 @@
toolbar: toolbar:
clear: 清除 clear: 清除
download: 下载 download: 下载
download-filtered: 下载筛选的日志
search: 搜索 search: 搜索
show: 显示 {std} show: 显示 {std}
show-all: 显示全部 show-all: 显示全部