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:
2
assets/auto-imports.d.ts
vendored
2
assets/auto-imports.d.ts
vendored
@@ -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']>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
45
assets/composable/downloadUrl.ts
Normal file
45
assets/composable/downloadUrl.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Clear
|
||||
download: Download
|
||||
download-filtered: Download Filtered Logs
|
||||
search: Search
|
||||
show: Show {std}
|
||||
show-all: Show all
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Limpiar
|
||||
download: Descargar
|
||||
download-filtered: Descargar Logs Filtrados
|
||||
search: Buscar
|
||||
show: Mostrar {std}
|
||||
show-all: Mostrar todo
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Bersihkan
|
||||
download: Unduh
|
||||
download-filtered: Unduh Log yang Difilter
|
||||
search: Cari
|
||||
show: Tampilkan {std}
|
||||
show-all: Tampilkan semua
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Pulisci
|
||||
download: Scarica
|
||||
download-filtered: Scarica Log Filtrati
|
||||
search: Cerca
|
||||
show: Mostra solo {std}
|
||||
show-all: Mostra tutto
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: 지우기
|
||||
download: 다운로드
|
||||
download-filtered: 필터링된 로그 다운로드
|
||||
search: 검색
|
||||
show: "{std} 보기"
|
||||
show-all: 전체 보기
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Wissen
|
||||
download: Downloaden
|
||||
download-filtered: Gefilterde Logs Downloaden
|
||||
search: Zoeken
|
||||
show: Toon {std}
|
||||
show-all: Toon alles
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Wyczyść
|
||||
download: Pobierz
|
||||
download-filtered: Pobierz Filtrowane Logi
|
||||
search: Szukaj
|
||||
show: Pokaż tylko {std}
|
||||
show-all: Pokaż wszystko
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Limpar
|
||||
download: Baixar
|
||||
download-filtered: Baixar Logs Filtrados
|
||||
search: Pesquisar
|
||||
show: Mostrar {std}
|
||||
show-all: Mostrar tudo
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: Очистить
|
||||
download: Скачать
|
||||
download-filtered: Скачать Отфильтрованные Логи
|
||||
search: Поиск
|
||||
show: Показать только {std}
|
||||
show-all: Показать все потоки
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: 清除
|
||||
download: 下載
|
||||
download-filtered: 下載篩選的日誌
|
||||
search: 搜尋
|
||||
show: 僅顯示 {std}
|
||||
show-all: 顯示全部
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
toolbar:
|
||||
clear: 清除
|
||||
download: 下载
|
||||
download-filtered: 下载筛选的日志
|
||||
search: 搜索
|
||||
show: 显示 {std}
|
||||
show-all: 显示全部
|
||||
|
||||
Reference in New Issue
Block a user