From 7c5ca010277fdfbfe2ad780f6f92ce5b9baebe62 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 30 May 2026 20:59:01 -0700 Subject: [PATCH] admin: export file/folder metadata from the file browser (#9750) Add a per-row Export button (files and folders) that downloads the filer metadata in the length-prefixed FullEntry protobuf format that weed shell fs.meta.load reads, gzipped as .meta.gz like fs.meta.save. Folders are walked recursively via the filer BFS metadata stream, excluding the system log subtree. Streamed over gRPC so it keeps working with the filer HTTP listener disabled. --- weed/admin/handlers/admin_handlers.go | 1 + weed/admin/handlers/file_browser_handlers.go | 94 ++++++++++++++++- weed/admin/view/app/file_browser.templ | 13 ++- weed/admin/view/app/file_browser_templ.go | 101 +++++++++++-------- 4 files changed, 163 insertions(+), 46 deletions(-) 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, "
Empty Directory

This directory contains no files or subdirectories.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
Empty Directory

This directory contains no files or subdirectories.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -672,123 +685,123 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " entries per page
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, ">200 entries per page
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.HasNextPage { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "Next ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"btn btn-outline-primary\">Next ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
Last updated: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 256, Col: 66} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
Create New Folder
Folder names cannot contain / or \\ characters.
Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath) + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 281, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 259, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\">
Upload Files
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.
Create New Folder
Folder names cannot contain / or \\ characters.
0%
Preparing upload...
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\">
Upload Files
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.
0%
Preparing upload...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }