1
0
mirror of https://github.com/amir20/dozzle.git synced 2026-01-03 11:35:00 +01:00

feat: supports downloading a group of containers in a zip file (#3490)

This commit is contained in:
Amir Raminfar
2024-12-30 09:24:55 -08:00
committed by GitHub
parent 984452c181
commit d93efedc11
7 changed files with 124 additions and 73 deletions

View File

@@ -280,6 +280,7 @@ declare global {
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useProfileStorage: typeof import('./composable/profileStorage')['useProfileStorage']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
@@ -288,6 +289,7 @@ declare global {
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
@@ -665,6 +667,7 @@ declare module 'vue' {
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProfileStorage: UnwrapRef<typeof import('./composable/profileStorage')['useProfileStorage']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
@@ -673,6 +676,7 @@ declare module 'vue' {
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>

View File

@@ -169,7 +169,7 @@ const downloadParams = computed(() =>
const downloadUrl = computed(() =>
withBase(
`/api/hosts/${container.host}/containers/${container.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`,
`/api/containers/${container.host}:${container.id}/download?${new URLSearchParams(downloadParams.value).toString()}`,
),
);

View File

@@ -11,6 +11,9 @@
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a>
</li>
<li>
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
</li>
<li>
<a @click.prevent="showSearch = true">
<mdi:magnify /> {{ $t("toolbar.search") }}
@@ -84,7 +87,19 @@ const { showSearch } = useSearchFilter();
const clear = defineEmit();
const { streamConfig, showHostname, showContainerName } = useLoggingContext();
const { streamConfig, showHostname, showContainerName, containers } = 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()}`,
),
);
</script>
<style scoped lang="postcss">

100
internal/web/download.go Normal file
View File

@@ -0,0 +1,100 @@
package web
import (
"archive/zip"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/docker"
"github.com/docker/docker/pkg/stdcopy"
"github.com/go-chi/chi/v5"
)
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
hostIds := strings.Split(chi.URLParam(r, "hostIds"), ",")
if len(hostIds) == 0 {
http.Error(w, "no container ids provided", http.StatusBadRequest)
return
}
usersFilter := h.config.Filter
if h.config.Authorization.Provider != NONE {
user := auth.UserFromContext(r.Context())
if user.ContainerFilter.Exists() {
usersFilter = user.ContainerFilter
}
}
now := time.Now()
nowFmt := now.Format("2006-01-02T15-04-05")
var stdTypes docker.StdType
if r.URL.Query().Has("stdout") {
stdTypes |= docker.STDOUT
}
if r.URL.Query().Has("stderr") {
stdTypes |= docker.STDERR
}
if stdTypes == 0 {
http.Error(w, "stdout or stderr is required", http.StatusBadRequest)
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")
// Create zip writer
zw := zip.NewWriter(w)
defer zw.Close()
// Process each container
for _, hostId := range hostIds {
parts := strings.Split(hostId, ":")
if len(parts) != 2 {
http.Error(w, fmt.Sprintf("invalid host id: %s", hostId), http.StatusBadRequest)
return
}
host := parts[0]
id := parts[1]
containerService, err := h.multiHostService.FindContainer(host, id, usersFilter)
if err != nil {
http.Error(w, fmt.Sprintf("error finding container %s: %v", id, err), http.StatusBadRequest)
return
}
// Create new file in zip for this container's logs
fileName := fmt.Sprintf("%s-%s.log", containerService.Container.Name, nowFmt)
f, err := zw.Create(fileName)
if err != nil {
http.Error(w, fmt.Sprintf("error creating zip entry: %v", err), http.StatusInternalServerError)
return
}
// Get container logs
reader, err := containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
if err != nil {
http.Error(w, fmt.Sprintf("error getting logs for container %s: %v", id, err), http.StatusInternalServerError)
return
}
// Copy logs directly to zip entry
if containerService.Container.Tty {
if _, err := io.Copy(f, reader); err != nil {
http.Error(w, fmt.Sprintf("error copying logs for container %s: %v", id, err), http.StatusInternalServerError)
return
}
} else {
if _, err := stdcopy.StdCopy(f, f, reader); err != nil {
http.Error(w, fmt.Sprintf("error copying logs for container %s: %v", id, err), http.StatusInternalServerError)
return
}
}
}
}

View File

@@ -2,7 +2,6 @@ package web
import (
"bytes"
"compress/gzip"
"io"
"time"
@@ -11,14 +10,13 @@ import (
"testing"
"github.com/amir20/dozzle/internal/docker"
"github.com/beme/abide"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func Test_handler_download_logs(t *testing.T) {
id := "123456"
req, err := http.NewRequest("GET", "/api/hosts/localhost/containers/"+id+"/logs/download?stdout=1", nil)
req, err := http.NewRequest("GET", "/api/containers/localhost:"+id+"/download?stdout=1", nil)
require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient)
@@ -40,7 +38,6 @@ func Test_handler_download_logs(t *testing.T) {
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
reader, _ := gzip.NewReader(rr.Body)
abide.AssertReader(t, t.Name(), reader)
require.Equal(t, http.StatusOK, rr.Code, "Status code should be 200.")
mockedClient.AssertExpectations(t)
}

View File

@@ -1,7 +1,6 @@
package web
import (
"compress/gzip"
"context"
"errors"
"regexp"
@@ -11,7 +10,6 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
@@ -23,75 +21,12 @@ import (
"github.com/amir20/dozzle/internal/support/search"
support_web "github.com/amir20/dozzle/internal/support/web"
"github.com/amir20/dozzle/internal/utils"
"github.com/docker/docker/pkg/stdcopy"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
usersFilter := h.config.Filter
if h.config.Authorization.Provider != NONE {
user := auth.UserFromContext(r.Context())
if user.ContainerFilter.Exists() {
usersFilter = user.ContainerFilter
}
}
containerService, err := h.multiHostService.FindContainer(hostKey(r), id, usersFilter)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
now := time.Now()
nowFmt := now.Format("2006-01-02T15-04-05")
contentDisposition := fmt.Sprintf("attachment; filename=%s-%s.log", containerService.Container.Name, nowFmt)
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Disposition", contentDisposition)
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Type", "application/text")
} else {
w.Header().Set("Content-Disposition", contentDisposition+".gz")
w.Header().Set("Content-Type", "application/gzip")
}
var stdTypes docker.StdType
if r.URL.Query().Has("stdout") {
stdTypes |= docker.STDOUT
}
if r.URL.Query().Has("stderr") {
stdTypes |= docker.STDERR
}
if stdTypes == 0 {
http.Error(w, "stdout or stderr is required", http.StatusBadRequest)
return
}
zw := gzip.NewWriter(w)
defer zw.Close()
zw.Name = fmt.Sprintf("%s-%s.log", containerService.Container.Name, nowFmt)
zw.Comment = "Logs generated by Dozzle"
zw.ModTime = now
reader, err := containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if containerService.Container.Tty {
io.Copy(zw, reader)
} else {
stdcopy.StdCopy(zw, zw, reader)
}
}
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-jsonl; charset=UTF-8")

View File

@@ -94,9 +94,9 @@ func createRouter(h *handler) *chi.Mux {
r.Use(auth.RequireAuthentication)
}
r.Get("/hosts/{host}/containers/{id}/logs/stream", h.streamContainerLogs)
r.Get("/hosts/{host}/containers/{id}/logs/download", h.downloadLogs)
r.Get("/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates)
r.Get("/hosts/{host}/logs/mergedStream/{ids}", h.streamLogsMerged)
r.Get("/containers/{hostIds}/download", h.downloadLogs) // formatted as host:container,host:container
r.Get("/stacks/{stack}/logs/stream", h.streamStackLogs)
r.Get("/services/{service}/logs/stream", h.streamServiceLogs)
r.Get("/groups/{group}/logs/stream", h.streamGroupedLogs)