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:
4
assets/auto-imports.d.ts
vendored
4
assets/auto-imports.d.ts
vendored
@@ -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']>
|
||||
|
||||
@@ -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()}`,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
100
internal/web/download.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user