Skip to content

Commit bafcbf6

Browse files
authored
feat: enables filters and level filters when downloading (#4251)
1 parent 80df836 commit bafcbf6

File tree

22 files changed

+182
-59
lines changed

22 files changed

+182
-59
lines changed

assets/auto-imports.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ declare global {
218218
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
219219
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
220220
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
221+
const useDownloadUrl: typeof import('./composable/downloadUrl')['useDownloadUrl']
221222
const useDraggable: typeof import('@vueuse/core')['useDraggable']
222223
const useDrawer: typeof import('./composable/drawer')['useDrawer']
223224
const useDropZone: typeof import('@vueuse/core')['useDropZone']
@@ -616,6 +617,7 @@ declare module 'vue' {
616617
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
617618
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
618619
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
620+
readonly useDownloadUrl: UnwrapRef<typeof import('./composable/downloadUrl')['useDownloadUrl']>
619621
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
620622
readonly useDrawer: UnwrapRef<typeof import('./composable/drawer')['useDrawer']>
621623
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>

assets/components/ContainerViewer/ContainerActionsToolbar.vue

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
</a>
1717
</li>
1818
<li v-if="enableDownload">
19-
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
19+
<a :href="downloadUrl" download>
20+
<octicon:download-24 />
21+
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
22+
</a>
2023
</li>
2124
<li v-if="!historical">
2225
<a @click="showSearch = true">
@@ -199,17 +202,8 @@ if (enableShell) {
199202
});
200203
}
201204
202-
const downloadParams = computed(() =>
203-
Object.entries(toValue(streamConfig))
204-
.filter(([, value]) => value)
205-
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
206-
);
207-
208-
const downloadUrl = computed(() =>
209-
withBase(
210-
`/api/containers/${container.host}~${container.id}/download?${new URLSearchParams(downloadParams.value).toString()}`,
211-
),
212-
);
205+
const containerRef = computed(() => [container]);
206+
const { downloadUrl, isFiltered } = useDownloadUrl(containerRef, streamConfig, levels);
213207
214208
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);
215209

assets/components/LogViewer/MultiContainerActionToolbar.vue

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
</a>
1717
</li>
1818
<li v-if="enableDownload">
19-
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
19+
<a :href="downloadUrl" download>
20+
<octicon:download-24 />
21+
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
22+
</a>
2023
</li>
2124
<li>
2225
<a @click="showSearch = true">
@@ -91,19 +94,9 @@ const { showSearch } = useSearchFilter();
9194
const { enableDownload } = config;
9295
const clear = defineEmit();
9396
94-
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext();
95-
96-
const downloadParams = computed(() =>
97-
Object.entries(toValue(streamConfig))
98-
.filter(([, value]) => value)
99-
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
100-
);
97+
const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext();
10198
102-
const downloadUrl = computed(() =>
103-
withBase(
104-
`/api/containers/${containers.value.map((c) => c.host + "~" + c.id).join(",")}/download?${new URLSearchParams(downloadParams.value).toString()}`,
105-
),
106-
);
99+
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels);
107100
108101
const hideMenu = (e: MouseEvent) => {
109102
if (e.target instanceof HTMLAnchorElement) {

assets/composable/downloadUrl.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Container } from "@/models/Container";
2+
import { allLevels } from "@/composable/logContext";
3+
4+
export function useDownloadUrl(
5+
containers: Ref<Container[]> | ComputedRef<Container[]>,
6+
streamConfig: { stdout: boolean; stderr: boolean } | Ref<{ stdout: boolean; stderr: boolean }>,
7+
levels: Ref<Set<string>>,
8+
) {
9+
const { debouncedSearchFilter } = useSearchFilter();
10+
11+
const downloadUrl = computed(() => {
12+
const params = new URLSearchParams();
13+
const config = toValue(streamConfig);
14+
15+
// Add stdout/stderr
16+
if (config.stdout) params.append("stdout", "1");
17+
if (config.stderr) params.append("stderr", "1");
18+
19+
// Add filter if search is active
20+
if (debouncedSearchFilter.value) {
21+
params.append("filter", debouncedSearchFilter.value);
22+
}
23+
24+
// Add levels (multiple values) only if filtered
25+
const selectedLevels = Array.from(levels.value);
26+
if (selectedLevels.length > 0 && selectedLevels.length < allLevels.length) {
27+
selectedLevels.forEach((level) => params.append("levels", level));
28+
}
29+
30+
const containerIds = toValue(containers)
31+
.map((c) => c.host + "~" + c.id)
32+
.join(",");
33+
34+
return withBase(`/api/containers/${containerIds}/download?${params.toString()}`);
35+
});
36+
37+
const isFiltered = computed(
38+
() => debouncedSearchFilter.value || (levels.value.size > 0 && levels.value.size < allLevels.length),
39+
);
40+
41+
return {
42+
downloadUrl,
43+
isFiltered,
44+
};
45+
}

internal/web/download.go

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import (
55
"fmt"
66
"io"
77
"net/http"
8+
"regexp"
89
"strings"
910
"time"
1011

1112
"github.com/amir20/dozzle/internal/auth"
1213
"github.com/amir20/dozzle/internal/container"
14+
container_support "github.com/amir20/dozzle/internal/support/container"
15+
support_web "github.com/amir20/dozzle/internal/support/web"
1316
"github.com/go-chi/chi/v5"
1417
"github.com/rs/zerolog/log"
1518
)
@@ -54,15 +57,34 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
5457
return
5558
}
5659

57-
// Set headers for zip file
58-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=container-logs-%s.zip", nowFmt))
59-
w.Header().Set("Content-Type", "application/zip")
60+
// Parse filter regex if provided
61+
var regex *regexp.Regexp
62+
var err error
63+
if r.URL.Query().Has("filter") {
64+
regex, err = support_web.ParseRegex(r.URL.Query().Get("filter"))
65+
if err != nil {
66+
http.Error(w, err.Error(), http.StatusBadRequest)
67+
return
68+
}
69+
}
6070

61-
// Create zip writer
62-
zw := zip.NewWriter(w)
63-
defer zw.Close()
71+
// Parse level filters if provided
72+
levels := make(map[string]struct{})
73+
if r.URL.Query().Has("levels") {
74+
for _, level := range r.URL.Query()["levels"] {
75+
levels[level] = struct{}{}
76+
}
77+
}
78+
79+
// Validate all containers before starting to write response
80+
type containerInfo struct {
81+
hostId string
82+
host string
83+
id string
84+
containerService *container_support.ContainerService
85+
}
86+
containers := make([]containerInfo, 0, len(hostIds))
6487

65-
// Process each container
6688
for _, hostId := range hostIds {
6789
parts := strings.Split(hostId, "~")
6890
if len(parts) != 2 {
@@ -80,29 +102,79 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
80102
return
81103
}
82104

105+
containers = append(containers, containerInfo{
106+
hostId: hostId,
107+
host: host,
108+
id: id,
109+
containerService: containerService,
110+
})
111+
}
112+
113+
// Set headers for zip file
114+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=container-logs-%s.zip", nowFmt))
115+
w.Header().Set("Content-Type", "application/zip")
116+
117+
// Create zip writer
118+
zw := zip.NewWriter(w)
119+
defer zw.Close()
120+
121+
// Process each container - errors after this point are logged only since response has started
122+
for _, c := range containers {
83123
// Create new file in zip for this container's logs
84-
fileName := fmt.Sprintf("%s-%s.log", containerService.Container.Name, nowFmt)
124+
fileName := fmt.Sprintf("%s-%s.log", c.containerService.Container.Name, nowFmt)
85125
f, err := zw.Create(fileName)
86126
if err != nil {
87-
log.Error().Err(err).Msgf("error creating zip entry for container %s", id)
88-
http.Error(w, fmt.Sprintf("error creating zip entry: %v", err), http.StatusInternalServerError)
127+
log.Error().Err(err).Msgf("error creating zip entry for container %s", c.id)
89128
return
90129
}
91130

92-
// Get container logs
93-
reader, err := containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
94-
if err != nil {
95-
log.Error().Err(err).Msgf("error getting logs for container %s", id)
96-
http.Error(w, fmt.Sprintf("error getting logs for container %s: %v", id, err), http.StatusInternalServerError)
97-
return
98-
}
131+
// Get container logs - use LogsBetweenDates if filtering is needed, otherwise use RawLogs
132+
if regex != nil || len(levels) > 0 {
133+
// Fetch parsed log events for filtering
134+
events, err := c.containerService.LogsBetweenDates(r.Context(), time.Time{}, now, stdTypes)
135+
if err != nil {
136+
log.Error().Err(err).Msgf("error getting logs for container %s", c.id)
137+
return
138+
}
99139

100-
// Copy logs to zip file
101-
_, err = io.Copy(f, reader)
102-
if err != nil {
103-
log.Error().Err(err).Msgf("error copying logs for container %s", id)
104-
http.Error(w, fmt.Sprintf("error copying logs for container %s: %v", id, err), http.StatusInternalServerError)
105-
return
140+
// Filter and write events
141+
for event := range events {
142+
// Apply regex filter if provided
143+
if regex != nil && !support_web.Search(regex, event) {
144+
continue
145+
}
146+
147+
// Apply level filter if provided
148+
if len(levels) > 0 {
149+
if _, ok := levels[event.Level]; !ok {
150+
continue
151+
}
152+
}
153+
154+
// Format timestamp in UTC
155+
timestamp := time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano)
156+
157+
// Write timestamp followed by message
158+
_, err = fmt.Fprintf(f, "%s %s\n", timestamp, event.RawMessage)
159+
if err != nil {
160+
log.Error().Err(err).Msgf("error writing log for container %s", c.id)
161+
return
162+
}
163+
}
164+
} else {
165+
// No filtering needed, use raw logs for better performance
166+
reader, err := c.containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
167+
if err != nil {
168+
log.Error().Err(err).Msgf("error getting logs for container %s", c.id)
169+
return
170+
}
171+
172+
// Copy logs to zip file
173+
_, err = io.Copy(f, reader)
174+
if err != nil {
175+
log.Error().Err(err).Msgf("error copying logs for container %s", c.id)
176+
return
177+
}
106178
}
107179
}
108180
}

locales/da.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
toolbar:
22
clear: Ryd
33
download: Download
4+
download-filtered: Download Filtrerede Logs
45
search: Søg
56
show: Vis kun {std}
67
show-all: Vis alle streams

locales/de.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
toolbar:
22
clear: Leeren
33
download: Herunterladen
4+
download-filtered: Gefilterte Logs Herunterladen
45
search: Suchen
56
show: Zeige nur {std}
67
show-all: Zeige alle Streams

locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
toolbar:
22
clear: Clear
33
download: Download
4+
download-filtered: Download Filtered Logs
45
search: Search
56
show: Show {std}
67
show-all: Show all

locales/es.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
toolbar:
22
clear: Limpiar
33
download: Descargar
4+
download-filtered: Descargar Logs Filtrados
45
search: Buscar
56
show: Mostrar {std}
67
show-all: Mostrar todo

locales/fr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
toolbar:
22
clear: Effacer
33
download: Téléchargement
4+
download-filtered: Télécharger Logs Filtrés
45
search: Chercher
56
show: Montrer seulement {std}
67
show-all: Afficher tous les flux

0 commit comments

Comments
 (0)