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 <name>.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.
This commit is contained in:
Chris Lu
2026-05-30 20:59:01 -07:00
committed by GitHub
parent 3441a2a7f1
commit 7c5ca01027
4 changed files with 163 additions and 46 deletions
+1
View File
@@ -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)
+93 -1
View File
@@ -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
}
+12 -1
View File
@@ -198,6 +198,9 @@ templ FileBrowser(data dash.FileBrowserData) {
<button type="button" class="btn btn-outline-secondary btn-sm" title="Properties" data-action="properties" data-path={ entry.FullPath }>
<i class="fas fa-info-circle"></i>
</button>
<button type="button" class="btn btn-outline-success btn-sm" title="Export metadata" data-action="export-metadata" data-path={ entry.FullPath }>
<i class="fas fa-file-export"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Delete" data-action="delete" data-path={ entry.FullPath }>
<i class="fas fa-trash"></i>
</button>
@@ -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))
File diff suppressed because one or more lines are too long