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 }