diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 650df45cc..e8cf46721 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -246,6 +246,7 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { filesApi.HandleFunc("/download", h.fileBrowserHandlers.DownloadFile).Methods(http.MethodGet) filesApi.HandleFunc("/view", h.fileBrowserHandlers.ViewFile).Methods(http.MethodGet) filesApi.HandleFunc("/properties", h.fileBrowserHandlers.GetFileProperties).Methods(http.MethodGet) + filesApi.HandleFunc("/metadata", h.fileBrowserHandlers.ExportMetadata).Methods(http.MethodGet) volumeApi := api.PathPrefix("/volumes").Subrouter() volumeApi.Handle("/{id}/{server}/vacuum", wrapWrite(h.clusterHandlers.VacuumVolume)).Methods(http.MethodPost) diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index 419be4b1d..20893afdd 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -1,8 +1,12 @@ package handlers import ( + "bufio" + "compress/gzip" "context" "fmt" + "io" + "mime" "mime/multipart" "net/http" "os" @@ -15,10 +19,12 @@ import ( "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" + "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/util/http/client" + "google.golang.org/protobuf/proto" ) type FileBrowserHandlers struct { @@ -628,6 +634,93 @@ func (h *FileBrowserHandlers) GetFileProperties(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, properties) } +// ExportMetadata streams a file or folder's metadata as a gzipped, length-prefixed +// FullEntry stream — the format weed shell fs.meta.load reads. Directories are +// walked recursively via the filer BFS metadata stream. +func (h *FileBrowserHandlers) ExportMetadata(w http.ResponseWriter, r *http.Request) { + filePath := r.URL.Query().Get("path") + if filePath == "" { + writeJSONError(w, http.StatusBadRequest, "File path is required") + return + } + cleanPath, err := h.validateAndCleanFilePath(filePath) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + + tracker := &responseWriteTracker{ResponseWriter: w} + + err = h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + stream, err := client.TraverseBfsMetadata(r.Context(), &filer_pb.TraverseBfsMetadataRequest{ + Directory: cleanPath, + ExcludedPrefixes: []string{filer.SystemLogDir}, + }) + if err != nil { + return err + } + + // Read the first entry before sending headers so a bad path returns a clean error. + first, err := stream.Recv() + if err != nil { + return err + } + + downloadName := path.Base(cleanPath) + if downloadName == "/" || downloadName == "." || downloadName == "" { + downloadName = "root" + } + tracker.Header().Set("Content-Type", "application/gzip") + tracker.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": downloadName + ".meta.gz"})) + tracker.WriteHeader(http.StatusOK) + + bw := bufio.NewWriter(tracker) + gw := gzip.NewWriter(bw) + + sizeBuf := make([]byte, 4) + writeEntry := func(resp *filer_pb.TraverseBfsMetadataResponse) error { + b, err := proto.Marshal(&filer_pb.FullEntry{Dir: resp.Directory, Entry: resp.Entry}) + if err != nil { + return err + } + util.Uint32toBytes(sizeBuf, uint32(len(b))) + if _, err := gw.Write(sizeBuf); err != nil { + return err + } + _, err = gw.Write(b) + return err + } + + if err := writeEntry(first); err != nil { + return err + } + for { + resp, recvErr := stream.Recv() + if recvErr == io.EOF { + break + } + if recvErr != nil { + return recvErr + } + if err := writeEntry(resp); err != nil { + return err + } + } + + if err := gw.Close(); err != nil { + return err + } + return bw.Flush() + }) + if err != nil { + if tracker.committed { + glog.Errorf("Error exporting metadata for %s: %v", cleanPath, err) + return + } + writeJSONError(w, http.StatusInternalServerError, "Failed to export metadata: "+err.Error()) + } +} + // Helper function to format bytes func (h *FileBrowserHandlers) formatBytes(bytes int64) string { const unit = 1024 @@ -685,4 +778,3 @@ func min(a, b int64) int64 { } return b } - diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ index 0421860b1..bc35781e2 100644 --- a/weed/admin/view/app/file_browser.templ +++ b/weed/admin/view/app/file_browser.templ @@ -198,6 +198,9 @@ templ FileBrowser(data dash.FileBrowserData) { + @@ -354,7 +357,7 @@ templ FileBrowser(data dash.FileBrowserData) { } }); - // Handle file browser action buttons (download, view, properties, delete) + // Handle file browser action buttons (download, view, properties, export-metadata, delete) document.addEventListener('click', function(e) { const button = e.target.closest('[data-action]'); if (!button) return; @@ -374,6 +377,9 @@ templ FileBrowser(data dash.FileBrowserData) { case 'properties': showFileProperties(path); break; + case 'export-metadata': + exportMetadata(path); + break; case 'delete': const fileName = path.split('/').pop(); showDeleteConfirm(fileName, function() { @@ -400,6 +406,11 @@ templ FileBrowser(data dash.FileBrowserData) { window.open((window.__BASE_PATH__ || '') + '/api/files/view?path=' + encodeURIComponent(path), '_blank'); } + function exportMetadata(path) { + // Download metadata as an fs.meta.load-compatible .meta.gz + window.open((window.__BASE_PATH__ || '') + '/api/files/metadata?path=' + encodeURIComponent(path), '_blank'); + } + function showFileProperties(path) { // Fetch file properties and show in modal fetch((window.__BASE_PATH__ || '') + '/api/files/properties?path=' + encodeURIComponent(path)) diff --git a/weed/admin/view/app/file_browser_templ.go b/weed/admin/view/app/file_browser_templ.go index 8d177ba3f..1f1b3b53a 100644 --- a/weed/admin/view/app/file_browser_templ.go +++ b/weed/admin/view/app/file_browser_templ.go @@ -636,35 +636,48 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"> ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
This directory contains no files or subdirectories.
This directory contains no files or subdirectories.