Files
Chris LuandGitHub 60e7b30009 admin: browse Iceberg table data (#10227)
* admin: move volume-server read JWT helper into dash

The Iceberg data preview page needs the same per-fileId read token the
file browser uses when streaming chunks from volume servers.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: add Iceberg table data preview page

The admin UI browses the Iceberg catalog down to table details but not
the data itself. Add a Browse Data page per table that walks the
selected snapshot's manifests and shows sample rows from its Parquet
data files, plus the data file list with per-file preview, a snapshot
switcher, and a row limit selector.

Rows are read through a ranged ReaderAt over stream-content so only
the Parquet footer and needed pages are fetched, with the volume read
JWT applied when configured. Iceberg locations resolve into /buckets
with traversal guards, and the file parameter must match a
manifest-listed data file. Snapshots with delete files get a warning
that raw rows are shown.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: integration test for Iceberg catalog and data preview pages

Starts a weed mini cluster with the admin UI, creates a table bucket,
namespace, and tables via the S3 Tables manager, uploads real Parquet
files via S3, writes manifests and snapshots with iceberg-go, and
asserts on the rendered pages: catalog browsing, table details,
current and historical snapshot previews, per-file preview, row
limits, unknown snapshot and file errors, and a metadata-less table.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: write Iceberg preview chunk reads straight into the caller slice

ReadAt wrapped the caller's buffer in a bytes.Buffer, which would
silently allocate a fresh backing array and drop bytes if it ever grew.
Copy directly into the destination slice and reject negative offsets so
the ReaderAt contract holds.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: link to snapshot history when the preview switcher truncates

The snapshot switcher caps at 25 entries; add a trailing item pointing
at the table details page so older snapshots stay reachable.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* test: hoist mini cluster context assignment out of the goroutine

Set MiniClusterCtx before launching the cluster goroutine and clear it
in stop(), so the assignment is not buried in the command loop.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur
2026-07-03 14:02:44 -07:00

269 lines
8.1 KiB
Go

package handlers
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
)
// Admin upload chunk size, matching s3api so files split into the same fid-sized pieces.
const adminUploadChunkSize = 8 * 1024 * 1024
// File browser handlers backed by the filer gRPC service. They bypass the
// filer's HTTP listener so the UI keeps working when the filer is started
// with -disableHttp=true; chunk bytes still flow through the volume server
// HTTP endpoints (which run on their own ports).
// fetchFileContentGrpc reads file content via the filer gRPC service, looking
// the entry up and then streaming the chunks straight from the volume servers.
// When maxBytes > 0 the stream is truncated to that many bytes — used by the
// "is this text?" sniff so unknown-MIME files don't get fully downloaded.
func (h *FileBrowserHandlers) fetchFileContentGrpc(ctx context.Context, filePath string, maxBytes int) (string, error) {
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return "", err
}
entry, err := h.lookupEntry(ctx, cleanFilePath)
if err != nil {
return "", err
}
if entry.IsDirectory {
return "", fmt.Errorf("path is a directory")
}
size := int64(filer.FileSize(entry))
streamSize := size
if maxBytes > 0 && streamSize > int64(maxBytes) {
streamSize = int64(maxBytes)
}
var buf bytes.Buffer
if err := h.streamEntryContent(ctx, entry, streamSize, &buf); err != nil {
return "", err
}
return buf.String(), nil
}
// downloadFileGrpc streams a file via gRPC + volume server HTTP. The
// response writer receives the canonical attachment headers and the raw
// bytes; this replaces the HTTP-to-filer proxy that used to run in
// DownloadFile.
func (h *FileBrowserHandlers) downloadFileGrpc(ctx context.Context, filePath string, w http.ResponseWriter, inline bool) error {
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return err
}
entry, err := h.lookupEntry(ctx, cleanFilePath)
if err != nil {
return err
}
if entry.IsDirectory {
return fmt.Errorf("path is a directory")
}
size := int64(filer.FileSize(entry))
fileName := path.Base(cleanFilePath)
// Resolve mime like the viewer does so Content-Type and the inline check agree.
contentType := dash.ResolveEntryMime(entry)
// Only inline images and PDFs; serve the rest as attachments so a hostile upload
// (HTML, SVG) can't run as same-origin script. nosniff locks the declared type.
disposition := "attachment"
if inline && (strings.HasPrefix(contentType, "image/") || contentType == "application/pdf") {
disposition = "inline"
}
w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": fileName}))
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
return h.streamEntryContent(ctx, entry, size, w)
}
// uploadFileGrpc streams the upload to volumes in 8 MiB chunks via the shared
// chunked-upload helper, then registers the entry over the filer gRPC service.
// Content always lands in volumes, never inlined on the entry.
func (h *FileBrowserHandlers) uploadFileGrpc(ctx context.Context, filePath string, fileName string, mimeType string, reader io.Reader) error {
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return err
}
dir := path.Dir(cleanFilePath)
if dir == "." {
dir = "/"
}
entryName := path.Base(cleanFilePath)
if mimeType == "" {
mimeType = "application/octet-stream"
}
assignFunc := func(ctx context.Context, count int, expectedDataSize uint64) (*operation.VolumeAssignRequest, *operation.AssignResult, error) {
var assignResp *filer_pb.AssignVolumeResponse
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, assignErr := client.AssignVolume(ctx, &filer_pb.AssignVolumeRequest{
Count: int32(count),
Path: cleanFilePath,
ExpectedDataSize: expectedDataSize,
})
if assignErr != nil {
return assignErr
}
if resp.Error != "" {
return fmt.Errorf("%s", resp.Error)
}
assignResp = resp
return nil
})
if err != nil {
return nil, nil, err
}
if assignResp.Location == nil || assignResp.FileId == "" {
return nil, nil, fmt.Errorf("assign volume returned empty location")
}
return nil, &operation.AssignResult{
Fid: assignResp.FileId,
Url: assignResp.Location.Url,
PublicUrl: assignResp.Location.PublicUrl,
Count: uint64(count),
Auth: security.EncodedJwt(assignResp.Auth),
}, nil
}
chunkResult, err := operation.UploadReaderInChunks(ctx, reader, &operation.ChunkedUploadOption{
ChunkSize: adminUploadChunkSize,
MimeType: mimeType,
AssignFunc: assignFunc,
})
if err != nil {
// Partial chunks come back even on error so we can clean them up rather
// than leaving orphaned data on volume servers.
if chunkResult != nil && len(chunkResult.FileChunks) > 0 {
h.deleteOrphanedChunks(chunkResult.FileChunks)
}
return fmt.Errorf("upload: %w", err)
}
now := time.Now()
entry := &filer_pb.Entry{
Name: entryName,
Attributes: &filer_pb.FuseAttributes{
FileSize: uint64(chunkResult.TotalSize),
Mtime: now.Unix(),
Crtime: now.Unix(),
FileMode: 0644,
Mime: mimeType,
},
}
entry.Chunks = chunkResult.FileChunks
err = h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, createErr := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: entry,
})
return createErr
})
if err != nil {
if len(chunkResult.FileChunks) > 0 {
h.deleteOrphanedChunks(chunkResult.FileChunks)
}
return fmt.Errorf("create entry: %w", err)
}
return nil
}
// deleteOrphanedChunks best-effort removes the chunk fids when an upload
// fails partway through. Errors are logged; we can't surface them past the
// caller's primary failure.
func (h *FileBrowserHandlers) deleteOrphanedChunks(chunks []*filer_pb.FileChunk) {
fileIds := make([]string, 0, len(chunks))
for _, c := range chunks {
if fid := c.GetFileIdString(); fid != "" {
fileIds = append(fileIds, fid)
}
}
if len(fileIds) == 0 {
return
}
master := h.adminServer.GetMasterClient()
results := operation.DeleteFileIds(master.GetMaster, false, h.adminServer.GetGrpcDialOption(), fileIds)
for _, r := range results {
if r.Error != "" {
glog.Warningf("admin file browser: orphan chunk %s cleanup: %s", r.FileId, r.Error)
}
}
}
func (h *FileBrowserHandlers) lookupEntry(ctx context.Context, cleanFilePath string) (*filer_pb.Entry, error) {
dir := path.Dir(cleanFilePath)
if dir == "." {
dir = "/"
}
name := path.Base(cleanFilePath)
var entry *filer_pb.Entry
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, lookupErr := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if lookupErr != nil {
return lookupErr
}
if resp.Entry == nil {
return fmt.Errorf("not found")
}
entry = resp.Entry
return nil
})
return entry, err
}
func (h *FileBrowserHandlers) streamEntryContent(ctx context.Context, entry *filer_pb.Entry, size int64, w io.Writer) error {
if size == 0 {
// Inline content (small files stored directly on the entry) skip the
// chunk pipeline entirely.
if len(entry.Content) > 0 {
_, err := w.Write(entry.Content)
return err
}
return nil
}
if len(entry.Content) > 0 && len(entry.GetChunks()) == 0 {
_, err := w.Write(entry.Content)
return err
}
streamFn, err := filer.PrepareStreamContentWithThrottler(
ctx,
h.adminServer.GetMasterClient(),
dash.VolumeServerReadJwt,
entry.GetChunks(),
0,
size,
0,
)
if err != nil {
return fmt.Errorf("prepare stream: %w", err)
}
return streamFn(w)
}