Skip to content

Commit d93efed

Browse files
authored
feat: supports downloading a group of containers in a zip file (#3490)
1 parent 984452c commit d93efed

File tree

7 files changed

+124
-73
lines changed

7 files changed

+124
-73
lines changed

assets/auto-imports.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ declare global {
280280
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
281281
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
282282
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
283+
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
283284
const usePrevious: typeof import('@vueuse/core')['usePrevious']
284285
const useProfileStorage: typeof import('./composable/profileStorage')['useProfileStorage']
285286
const useRafFn: typeof import('@vueuse/core')['useRafFn']
@@ -288,6 +289,7 @@ declare global {
288289
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
289290
const useRoute: typeof import('vue-router')['useRoute']
290291
const useRouter: typeof import('vue-router')['useRouter']
292+
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
291293
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
292294
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
293295
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
@@ -665,6 +667,7 @@ declare module 'vue' {
665667
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
666668
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
667669
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
670+
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
668671
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
669672
readonly useProfileStorage: UnwrapRef<typeof import('./composable/profileStorage')['useProfileStorage']>
670673
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
@@ -673,6 +676,7 @@ declare module 'vue' {
673676
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
674677
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
675678
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
679+
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
676680
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
677681
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
678682
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>

assets/components/ContainerViewer/ContainerActionsToolbar.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ const downloadParams = computed(() =>
169169
170170
const downloadUrl = computed(() =>
171171
withBase(
172-
`/api/hosts/${container.host}/containers/${container.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`,
172+
`/api/containers/${container.host}:${container.id}/download?${new URLSearchParams(downloadParams.value).toString()}`,
173173
),
174174
);
175175

assets/components/LogViewer/MultiContainerActionToolbar.vue

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
1212
</a>
1313
</li>
14+
<li>
15+
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
16+
</li>
1417
<li>
1518
<a @click.prevent="showSearch = true">
1619
<mdi:magnify /> {{ $t("toolbar.search") }}
@@ -84,7 +87,19 @@ const { showSearch } = useSearchFilter();
8487
8588
const clear = defineEmit();
8689
87-
const { streamConfig, showHostname, showContainerName } = useLoggingContext();
90+
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext();
91+
92+
const downloadParams = computed(() =>
93+
Object.entries(toValue(streamConfig))
94+
.filter(([, value]) => value)
95+
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
96+
);
97+
98+
const downloadUrl = computed(() =>
99+
withBase(
100+
`/api/containers/${containers.value.map((c) => c.host + ":" + c.id).join(",")}/download?${new URLSearchParams(downloadParams.value).toString()}`,
101+
),
102+
);
88103
</script>
89104

90105
<style scoped lang="postcss">

internal/web/download.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package web
2+
3+
import (
4+
"archive/zip"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"time"
10+
11+
"github.com/amir20/dozzle/internal/auth"
12+
"github.com/amir20/dozzle/internal/docker"
13+
"github.com/docker/docker/pkg/stdcopy"
14+
"github.com/go-chi/chi/v5"
15+
)
16+
17+
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
18+
hostIds := strings.Split(chi.URLParam(r, "hostIds"), ",")
19+
if len(hostIds) == 0 {
20+
http.Error(w, "no container ids provided", http.StatusBadRequest)
21+
return
22+
}
23+
24+
usersFilter := h.config.Filter
25+
if h.config.Authorization.Provider != NONE {
26+
user := auth.UserFromContext(r.Context())
27+
if user.ContainerFilter.Exists() {
28+
usersFilter = user.ContainerFilter
29+
}
30+
}
31+
32+
now := time.Now()
33+
nowFmt := now.Format("2006-01-02T15-04-05")
34+
35+
var stdTypes docker.StdType
36+
if r.URL.Query().Has("stdout") {
37+
stdTypes |= docker.STDOUT
38+
}
39+
if r.URL.Query().Has("stderr") {
40+
stdTypes |= docker.STDERR
41+
}
42+
43+
if stdTypes == 0 {
44+
http.Error(w, "stdout or stderr is required", http.StatusBadRequest)
45+
return
46+
}
47+
48+
// Set headers for zip file
49+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=container-logs-%s.zip", nowFmt))
50+
w.Header().Set("Content-Type", "application/zip")
51+
52+
// Create zip writer
53+
zw := zip.NewWriter(w)
54+
defer zw.Close()
55+
56+
// Process each container
57+
for _, hostId := range hostIds {
58+
parts := strings.Split(hostId, ":")
59+
if len(parts) != 2 {
60+
http.Error(w, fmt.Sprintf("invalid host id: %s", hostId), http.StatusBadRequest)
61+
return
62+
}
63+
64+
host := parts[0]
65+
id := parts[1]
66+
containerService, err := h.multiHostService.FindContainer(host, id, usersFilter)
67+
if err != nil {
68+
http.Error(w, fmt.Sprintf("error finding container %s: %v", id, err), http.StatusBadRequest)
69+
return
70+
}
71+
72+
// Create new file in zip for this container's logs
73+
fileName := fmt.Sprintf("%s-%s.log", containerService.Container.Name, nowFmt)
74+
f, err := zw.Create(fileName)
75+
if err != nil {
76+
http.Error(w, fmt.Sprintf("error creating zip entry: %v", err), http.StatusInternalServerError)
77+
return
78+
}
79+
80+
// Get container logs
81+
reader, err := containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
82+
if err != nil {
83+
http.Error(w, fmt.Sprintf("error getting logs for container %s: %v", id, err), http.StatusInternalServerError)
84+
return
85+
}
86+
87+
// Copy logs directly to zip entry
88+
if containerService.Container.Tty {
89+
if _, err := io.Copy(f, reader); err != nil {
90+
http.Error(w, fmt.Sprintf("error copying logs for container %s: %v", id, err), http.StatusInternalServerError)
91+
return
92+
}
93+
} else {
94+
if _, err := stdcopy.StdCopy(f, f, reader); err != nil {
95+
http.Error(w, fmt.Sprintf("error copying logs for container %s: %v", id, err), http.StatusInternalServerError)
96+
return
97+
}
98+
}
99+
}
100+
}

internal/web/download_test.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package web
22

33
import (
44
"bytes"
5-
"compress/gzip"
65
"io"
76
"time"
87

@@ -11,14 +10,13 @@ import (
1110
"testing"
1211

1312
"github.com/amir20/dozzle/internal/docker"
14-
"github.com/beme/abide"
1513
"github.com/stretchr/testify/mock"
1614
"github.com/stretchr/testify/require"
1715
)
1816

1917
func Test_handler_download_logs(t *testing.T) {
2018
id := "123456"
21-
req, err := http.NewRequest("GET", "/api/hosts/localhost/containers/"+id+"/logs/download?stdout=1", nil)
19+
req, err := http.NewRequest("GET", "/api/containers/localhost:"+id+"/download?stdout=1", nil)
2220
require.NoError(t, err, "NewRequest should not return an error.")
2321

2422
mockedClient := new(MockedClient)
@@ -40,7 +38,6 @@ func Test_handler_download_logs(t *testing.T) {
4038
handler := createDefaultHandler(mockedClient)
4139
rr := httptest.NewRecorder()
4240
handler.ServeHTTP(rr, req)
43-
reader, _ := gzip.NewReader(rr.Body)
44-
abide.AssertReader(t, t.Name(), reader)
41+
require.Equal(t, http.StatusOK, rr.Code, "Status code should be 200.")
4542
mockedClient.AssertExpectations(t)
4643
}

internal/web/logs.go

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package web
22

33
import (
4-
"compress/gzip"
54
"context"
65
"errors"
76
"regexp"
@@ -11,7 +10,6 @@ import (
1110

1211
"encoding/json"
1312

14-
"fmt"
1513
"io"
1614
"net/http"
1715
"runtime"
@@ -23,75 +21,12 @@ import (
2321
"github.com/amir20/dozzle/internal/support/search"
2422
support_web "github.com/amir20/dozzle/internal/support/web"
2523
"github.com/amir20/dozzle/internal/utils"
26-
"github.com/docker/docker/pkg/stdcopy"
2724
"github.com/dustin/go-humanize"
2825
"github.com/go-chi/chi/v5"
2926

3027
"github.com/rs/zerolog/log"
3128
)
3229

33-
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
34-
id := chi.URLParam(r, "id")
35-
36-
usersFilter := h.config.Filter
37-
if h.config.Authorization.Provider != NONE {
38-
user := auth.UserFromContext(r.Context())
39-
if user.ContainerFilter.Exists() {
40-
usersFilter = user.ContainerFilter
41-
}
42-
}
43-
44-
containerService, err := h.multiHostService.FindContainer(hostKey(r), id, usersFilter)
45-
if err != nil {
46-
http.Error(w, err.Error(), http.StatusBadRequest)
47-
return
48-
}
49-
50-
now := time.Now()
51-
nowFmt := now.Format("2006-01-02T15-04-05")
52-
53-
contentDisposition := fmt.Sprintf("attachment; filename=%s-%s.log", containerService.Container.Name, nowFmt)
54-
55-
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
56-
w.Header().Set("Content-Disposition", contentDisposition)
57-
w.Header().Set("Content-Encoding", "gzip")
58-
w.Header().Set("Content-Type", "application/text")
59-
} else {
60-
w.Header().Set("Content-Disposition", contentDisposition+".gz")
61-
w.Header().Set("Content-Type", "application/gzip")
62-
}
63-
64-
var stdTypes docker.StdType
65-
if r.URL.Query().Has("stdout") {
66-
stdTypes |= docker.STDOUT
67-
}
68-
if r.URL.Query().Has("stderr") {
69-
stdTypes |= docker.STDERR
70-
}
71-
72-
if stdTypes == 0 {
73-
http.Error(w, "stdout or stderr is required", http.StatusBadRequest)
74-
return
75-
}
76-
77-
zw := gzip.NewWriter(w)
78-
defer zw.Close()
79-
zw.Name = fmt.Sprintf("%s-%s.log", containerService.Container.Name, nowFmt)
80-
zw.Comment = "Logs generated by Dozzle"
81-
zw.ModTime = now
82-
83-
reader, err := containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
84-
if err != nil {
85-
http.Error(w, err.Error(), http.StatusInternalServerError)
86-
return
87-
}
88-
if containerService.Container.Tty {
89-
io.Copy(zw, reader)
90-
} else {
91-
stdcopy.StdCopy(zw, zw, reader)
92-
}
93-
}
94-
9530
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
9631
w.Header().Set("Content-Type", "application/x-jsonl; charset=UTF-8")
9732

internal/web/routes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ func createRouter(h *handler) *chi.Mux {
9494
r.Use(auth.RequireAuthentication)
9595
}
9696
r.Get("/hosts/{host}/containers/{id}/logs/stream", h.streamContainerLogs)
97-
r.Get("/hosts/{host}/containers/{id}/logs/download", h.downloadLogs)
9897
r.Get("/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates)
9998
r.Get("/hosts/{host}/logs/mergedStream/{ids}", h.streamLogsMerged)
99+
r.Get("/containers/{hostIds}/download", h.downloadLogs) // formatted as host:container,host:container
100100
r.Get("/stacks/{stack}/logs/stream", h.streamStackLogs)
101101
r.Get("/services/{service}/logs/stream", h.streamServiceLogs)
102102
r.Get("/groups/{group}/logs/stream", h.streamGroupedLogs)

0 commit comments

Comments
 (0)