diff --git a/assets/auto-imports.d.ts b/assets/auto-imports.d.ts index 66b8a29c..e98282f4 100644 --- a/assets/auto-imports.d.ts +++ b/assets/auto-imports.d.ts @@ -218,6 +218,7 @@ declare global { const useDevicesList: typeof import('@vueuse/core')['useDevicesList'] const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia'] const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility'] + const useDownloadUrl: typeof import('./composable/downloadUrl')['useDownloadUrl'] const useDraggable: typeof import('@vueuse/core')['useDraggable'] const useDrawer: typeof import('./composable/drawer')['useDrawer'] const useDropZone: typeof import('@vueuse/core')['useDropZone'] @@ -616,6 +617,7 @@ declare module 'vue' { readonly useDevicesList: UnwrapRef readonly useDisplayMedia: UnwrapRef readonly useDocumentVisibility: UnwrapRef + readonly useDownloadUrl: UnwrapRef readonly useDraggable: UnwrapRef readonly useDrawer: UnwrapRef readonly useDropZone: UnwrapRef diff --git a/assets/components/ContainerViewer/ContainerActionsToolbar.vue b/assets/components/ContainerViewer/ContainerActionsToolbar.vue index 3964e14a..00277f9f 100644 --- a/assets/components/ContainerViewer/ContainerActionsToolbar.vue +++ b/assets/components/ContainerViewer/ContainerActionsToolbar.vue @@ -16,7 +16,10 @@
  • - {{ $t("toolbar.download") }} + + + {{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }} +
  • @@ -199,17 +202,8 @@ if (enableShell) { }); } -const downloadParams = computed(() => - Object.entries(toValue(streamConfig)) - .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 containerRef = computed(() => [container]); +const { downloadUrl, isFiltered } = useDownloadUrl(containerRef, streamConfig, levels); const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart); diff --git a/assets/components/LogViewer/MultiContainerActionToolbar.vue b/assets/components/LogViewer/MultiContainerActionToolbar.vue index cd16b0db..74fdaff3 100644 --- a/assets/components/LogViewer/MultiContainerActionToolbar.vue +++ b/assets/components/LogViewer/MultiContainerActionToolbar.vue @@ -16,7 +16,10 @@
  • - {{ $t("toolbar.download") }} + + + {{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }} +
  • @@ -91,19 +94,9 @@ const { showSearch } = useSearchFilter(); const { enableDownload } = config; const clear = defineEmit(); -const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext(); +const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext(); -const downloadParams = computed(() => - 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 { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels); const hideMenu = (e: MouseEvent) => { if (e.target instanceof HTMLAnchorElement) { diff --git a/assets/composable/downloadUrl.ts b/assets/composable/downloadUrl.ts new file mode 100644 index 00000000..8f17fe29 --- /dev/null +++ b/assets/composable/downloadUrl.ts @@ -0,0 +1,45 @@ +import { Container } from "@/models/Container"; +import { allLevels } from "@/composable/logContext"; + +export function useDownloadUrl( + containers: Ref | ComputedRef, + streamConfig: { stdout: boolean; stderr: boolean } | Ref<{ stdout: boolean; stderr: boolean }>, + levels: Ref>, +) { + 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, + }; +} diff --git a/internal/web/download.go b/internal/web/download.go index 8baf7e6d..f2464cd7 100644 --- a/internal/web/download.go +++ b/internal/web/download.go @@ -5,11 +5,14 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" "time" "github.com/amir20/dozzle/internal/auth" "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/rs/zerolog/log" ) @@ -54,15 +57,34 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) { return } - // 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") + // Parse filter regex if provided + var regex *regexp.Regexp + 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 - zw := zip.NewWriter(w) - defer zw.Close() + // Parse level filters if provided + levels := make(map[string]struct{}) + 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 { parts := strings.Split(hostId, "~") if len(parts) != 2 { @@ -80,29 +102,79 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) { 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 - 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) if err != nil { - log.Error().Err(err).Msgf("error creating zip entry for container %s", id) - http.Error(w, fmt.Sprintf("error creating zip entry: %v", err), http.StatusInternalServerError) + log.Error().Err(err).Msgf("error creating zip entry for container %s", c.id) return } - // Get container logs - reader, err := containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes) - if err != nil { - log.Error().Err(err).Msgf("error getting logs for container %s", id) - http.Error(w, fmt.Sprintf("error getting logs for container %s: %v", id, err), http.StatusInternalServerError) - return - } + // Get container logs - use LogsBetweenDates if filtering is needed, otherwise use RawLogs + 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 { + log.Error().Err(err).Msgf("error getting logs for container %s", c.id) + return + } - // Copy logs to zip file - _, err = io.Copy(f, reader) - if err != nil { - log.Error().Err(err).Msgf("error copying logs for container %s", id) - http.Error(w, fmt.Sprintf("error copying 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 + } + + // Copy logs to zip file + _, err = io.Copy(f, reader) + if err != nil { + log.Error().Err(err).Msgf("error copying logs for container %s", c.id) + return + } } } } diff --git a/locales/da.yml b/locales/da.yml index ee429641..2aa368eb 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -1,6 +1,7 @@ toolbar: clear: Ryd download: Download + download-filtered: Download Filtrerede Logs search: Søg show: Vis kun {std} show-all: Vis alle streams diff --git a/locales/de.yml b/locales/de.yml index c6ec7ec2..0830c938 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -1,6 +1,7 @@ toolbar: clear: Leeren download: Herunterladen + download-filtered: Gefilterte Logs Herunterladen search: Suchen show: Zeige nur {std} show-all: Zeige alle Streams diff --git a/locales/en.yml b/locales/en.yml index 7ccdfac6..67a9ef80 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1,6 +1,7 @@ toolbar: clear: Clear download: Download + download-filtered: Download Filtered Logs search: Search show: Show {std} show-all: Show all diff --git a/locales/es.yml b/locales/es.yml index d628ba5e..aa4ab32b 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -1,6 +1,7 @@ toolbar: clear: Limpiar download: Descargar + download-filtered: Descargar Logs Filtrados search: Buscar show: Mostrar {std} show-all: Mostrar todo diff --git a/locales/fr.yml b/locales/fr.yml index 8a550817..bbe037f3 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -1,6 +1,7 @@ toolbar: clear: Effacer download: Téléchargement + download-filtered: Télécharger Logs Filtrés search: Chercher show: Montrer seulement {std} show-all: Afficher tous les flux diff --git a/locales/id.yml b/locales/id.yml index 25c6cce7..3554b79d 100644 --- a/locales/id.yml +++ b/locales/id.yml @@ -1,6 +1,7 @@ toolbar: clear: Bersihkan download: Unduh + download-filtered: Unduh Log yang Difilter search: Cari show: Tampilkan {std} show-all: Tampilkan semua diff --git a/locales/it.yml b/locales/it.yml index 07fc933d..a296c5b2 100644 --- a/locales/it.yml +++ b/locales/it.yml @@ -1,6 +1,7 @@ toolbar: clear: Pulisci download: Scarica + download-filtered: Scarica Log Filtrati search: Cerca show: Mostra solo {std} show-all: Mostra tutto diff --git a/locales/ko.yml b/locales/ko.yml index 4b76acc0..36e8b637 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -1,6 +1,7 @@ toolbar: clear: 지우기 download: 다운로드 + download-filtered: 필터링된 로그 다운로드 search: 검색 show: "{std} 보기" show-all: 전체 보기 diff --git a/locales/nl.yml b/locales/nl.yml index 1cd7cb87..01cdd425 100644 --- a/locales/nl.yml +++ b/locales/nl.yml @@ -1,6 +1,7 @@ toolbar: clear: Wissen download: Downloaden + download-filtered: Gefilterde Logs Downloaden search: Zoeken show: Toon {std} show-all: Toon alles diff --git a/locales/pl.yml b/locales/pl.yml index 14d8217f..11afa532 100644 --- a/locales/pl.yml +++ b/locales/pl.yml @@ -1,6 +1,7 @@ toolbar: clear: Wyczyść download: Pobierz + download-filtered: Pobierz Filtrowane Logi search: Szukaj show: Pokaż tylko {std} show-all: Pokaż wszystko diff --git a/locales/pr.yml b/locales/pr.yml index b40b4f11..a1c31ec4 100644 --- a/locales/pr.yml +++ b/locales/pr.yml @@ -1,6 +1,7 @@ toolbar: clear: Limpar download: Descarregar + download-filtered: Descarregar Logs Filtrados search: Pesquisa show: Mostrar apenas {std} show-all: Mostrar todos os fluxos diff --git a/locales/pt.yml b/locales/pt.yml index 903bd109..c37e9516 100644 --- a/locales/pt.yml +++ b/locales/pt.yml @@ -1,6 +1,7 @@ toolbar: clear: Limpar download: Baixar + download-filtered: Baixar Logs Filtrados search: Pesquisar show: Mostrar {std} show-all: Mostrar tudo diff --git a/locales/ru.yml b/locales/ru.yml index a4400bf5..36f92d7e 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -1,6 +1,7 @@ toolbar: clear: Очистить download: Скачать + download-filtered: Скачать Отфильтрованные Логи search: Поиск show: Показать только {std} show-all: Показать все потоки diff --git a/locales/sl.yml b/locales/sl.yml index 9be59009..185fb094 100644 --- a/locales/sl.yml +++ b/locales/sl.yml @@ -1,6 +1,7 @@ toolbar: clear: Počisti download: Prenesi + download-filtered: Prenesi Filtrirane Dnevnike search: Iskanje show: Prikaži {std} show-all: Prikaži vse @@ -52,12 +53,12 @@ error: logs-skipped: Prikaži {total} skritih vnosov events-stream: title: Nepričakovana napaka - message: Uporabniški vmesnik Dozzle se ni mogel povezati z API-jem. - Preverite omrežne nastavitve. Če uporabljate obratni proxy, se + message: Uporabniški vmesnik Dozzle se ni mogel povezati z API-jem. + Preverite omrežne nastavitve. Če uporabljate obratni proxy, se prepričajte, da je pravilno konfiguriran. events-timeout: title: Nekaj ni v redu - message: Časovna omejitev uporabniškega vmesnika Dozzle je potekla med + message: Časovna omejitev uporabniškega vmesnika Dozzle je potekla med povezovanjem z API-jem. Preverite omrežno povezavo in poskusite znova. container-not-found: Zabojnika ni bilo mogoče najti alert: @@ -66,8 +67,8 @@ alert: message: Dozzle vas je samodejno preusmeril v nov zabojnik {containerId}. similar-container-found: title: Najden podoben zabojnik - message: Dozzle je našel podoben zabojnik {containerId}, ki se izvaja na - istem gostitelju in bo samodejno preklopil nanj, razen če kliknete + message: Dozzle je našel podoben zabojnik {containerId}, ki se izvaja na + istem gostitelju in bo samodejno preklopil nanj, razen če kliknete »Prekliči«. title: page-not-found: Strani ni bilo mogoče najti @@ -96,7 +97,7 @@ settings: show-stopped-containers: Prikaži ustavljene zabojnike about: O programu search: Omogočite iskanje z Dozzle z uporabo - update-available: Nova različica je na voljo! Posodobi na {nextVersion}. show-std: Prikaži oznake stdout in stderr compact: Omogoči kompaktni način za dnevnike @@ -108,11 +109,11 @@ settings: start-line: To je večvrstično sporočilo o napaki middle-line: z drugo vrstico end-line: in nazadnje tretja vrstica. - simple: To je zelo zelo dolgo sporočilo, ki bi se privzeto prelomilo. Če - onemogočite mehke ovoje, bi to onemogočili. Lorem ipsum dolor sit amet, + simple: To je zelo zelo dolgo sporočilo, ki bi se privzeto prelomilo. Če + onemogočite mehke ovoje, bi to onemogočili. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - using-version: Uporabljaš Dozzle {version}. help-support: "Prosimo, podprite Dozzle z donacijo ali sponzoriranjem na GitHubu. Vaši prispevki nam pomagajo izboljšati Dozzle za vse. Hvala! 🙏🏼\n" @@ -121,8 +122,8 @@ releases: features: ena nova funkcija | {count} funkcij bugFixes: en popravek napake | {counr} popravkov breaking: ena ključna sprememba | {count} ključnih sprememb - three_parts: '{first}, {second} in {third}' - two_parts: '{first} s/z {second}' + three_parts: "{first}, {second} in {third}" + two_parts: "{first} s/z {second}" latest: Najnovejše no_releases: Imate najnovejšo različico log_actions: diff --git a/locales/tr.yml b/locales/tr.yml index efde8223..3d03f839 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -1,6 +1,7 @@ toolbar: clear: Temizle download: İndir + download-filtered: Filtrelenmiş Logları İndir search: Ara show: Sadece {std} göster show-all: Tüm akışları göster diff --git a/locales/zh-tw.yml b/locales/zh-tw.yml index 7bc618ef..e378a699 100644 --- a/locales/zh-tw.yml +++ b/locales/zh-tw.yml @@ -1,6 +1,7 @@ toolbar: clear: 清除 download: 下載 + download-filtered: 下載篩選的日誌 search: 搜尋 show: 僅顯示 {std} show-all: 顯示全部 diff --git a/locales/zh.yml b/locales/zh.yml index 0892d49d..69546ced 100644 --- a/locales/zh.yml +++ b/locales/zh.yml @@ -1,6 +1,7 @@ toolbar: clear: 清除 download: 下载 + download-filtered: 下载筛选的日志 search: 搜索 show: 显示 {std} show-all: 显示全部