1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-21 13:23:07 +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 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<typeof import('@vueuse/core')['useDevicesList']>
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
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 useDrawer: UnwrapRef<typeof import('./composable/drawer')['useDrawer']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>

View File

@@ -16,7 +16,10 @@
</a>
</li>
<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 v-if="!historical">
<a @click="showSearch = true">
@@ -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);

View File

@@ -16,7 +16,10 @@
</a>
</li>
<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>
<a @click="showSearch = true">
@@ -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) {

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"
"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
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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 <a href="{href}"
update-available: Nova različica je na voljo! Posodobi na <a href="{href}"
target="_blank" rel="noreferrer noopener">{nextVersion}</a>.
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š <a href="https://dozzle.dev/" target="_blank"
using-version: Uporabljaš <a href="https://dozzle.dev/" target="_blank"
rel="noreferrer noopener">Dozzle</a> {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:

View File

@@ -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

View File

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

View File

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