diff --git a/weed/admin/README.md b/weed/admin/README.md index 7b909174d..ce36f773d 100644 --- a/weed/admin/README.md +++ b/weed/admin/README.md @@ -8,6 +8,7 @@ A modern web-based administration interface for SeaweedFS clusters built with Go - **Master Management**: Monitor master nodes and leadership status - **Volume Server Management**: View volume servers, capacity, and health - **Object Store Bucket Management**: Create, delete, and manage Object Store buckets with web interface +- **S3 Tables Management**: Manage table buckets, namespaces, tables, tags, and policies via the admin UI - **System Health**: Overall cluster health monitoring - **Responsive Design**: Bootstrap-based UI that works on all devices - **Authentication**: Optional user authentication with sessions @@ -96,7 +97,6 @@ make fmt weed/admin/ ├── Makefile # Admin-specific build tasks ├── README.md # This file -├── S3_BUCKETS.md # Object Store bucket management documentation ├── admin.go # Main application entry point ├── dash/ # Server and handler logic │ ├── admin_server.go # HTTP server setup @@ -110,20 +110,20 @@ weed/admin/ ├── app/ # Application templates │ ├── admin.templ # Main dashboard template │ ├── s3_buckets.templ # Object Store bucket management template + │ ├── s3tables_*.templ # S3 Tables management templates │ └── *_templ.go # Generated Go code └── layout/ # Layout templates ├── layout.templ # Base layout template └── layout_templ.go # Generated Go code ``` -### S3 Bucket Management +### Object Store Management -The admin interface includes comprehensive Object Store bucket management capabilities. See [S3_BUCKETS.md](S3_BUCKETS.md) for detailed documentation on: +The admin interface includes Object Store and S3 Tables management capabilities: -- Creating and deleting Object Store buckets -- Viewing bucket contents and metadata -- Managing bucket permissions and settings -- API endpoints for programmatic access +- Create/delete Object Store buckets and adjust quotas or ownership. +- Manage S3 Tables buckets, namespaces, and tables. +- Update S3 Tables policies and tags via the UI and API endpoints. ## Usage @@ -276,4 +276,4 @@ The admin component follows a clean architecture: - **Business Logic**: Handler functions in `dash/` package - **Data Layer**: Communicates with SeaweedFS masters and filers -This separation makes the code maintainable and testable. \ No newline at end of file +This separation makes the code maintainable and testable. diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index f1e60252d..404227bf2 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -27,6 +27,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/s3api" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" _ "github.com/seaweedfs/seaweedfs/weed/credential/grpc" // Register gRPC credential store @@ -101,6 +102,8 @@ type AdminServer struct { collectionStatsCache map[string]collectionStats lastCollectionStatsUpdate time.Time collectionStatsCacheThreshold time.Duration + + s3TablesManager *s3tables.Manager } // Type definitions moved to types.go @@ -132,6 +135,7 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string) filerCacheExpiration: 30 * time.Second, // Cache filers for 30 seconds configPersistence: NewConfigPersistence(dataDir), collectionStatsCacheThreshold: 30 * time.Second, + s3TablesManager: newS3TablesManager(), } // Initialize topic retention purger diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go index 6e6e44c9d..d566033e7 100644 --- a/weed/admin/dash/file_browser_data.go +++ b/weed/admin/dash/file_browser_data.go @@ -39,9 +39,11 @@ type FileBrowserData struct { Breadcrumbs []BreadcrumbItem `json:"breadcrumbs"` Entries []FileEntry `json:"entries"` - LastUpdated time.Time `json:"last_updated"` - IsBucketPath bool `json:"is_bucket_path"` - BucketName string `json:"bucket_name"` + LastUpdated time.Time `json:"last_updated"` + IsBucketPath bool `json:"is_bucket_path"` + BucketName string `json:"bucket_name"` + IsTableBucketPath bool `json:"is_table_bucket_path"` + TableBucketName string `json:"table_bucket_name"` // Pagination fields PageSize int `json:"page_size"` HasNextPage bool `json:"has_next_page"` @@ -227,15 +229,28 @@ func (s *AdminServer) GetFileBrowser(dir string, lastFileName string, pageSize i } } + // Check if this is a table bucket path + isTableBucketPath := false + tableBucketName := "" + if strings.HasPrefix(dir, "/table-buckets/") { + isTableBucketPath = true + pathParts := strings.Split(strings.Trim(dir, "/"), "/") + if len(pathParts) >= 2 { + tableBucketName = pathParts[1] + } + } + return &FileBrowserData{ CurrentPath: dir, ParentPath: parentPath, Breadcrumbs: breadcrumbs, Entries: entries, - LastUpdated: time.Now(), - IsBucketPath: isBucketPath, - BucketName: bucketName, + LastUpdated: time.Now(), + IsBucketPath: isBucketPath, + BucketName: bucketName, + IsTableBucketPath: isTableBucketPath, + TableBucketName: tableBucketName, // Pagination metadata PageSize: pageSize, HasNextPage: hasNextPage, @@ -268,13 +283,17 @@ func (s *AdminServer) generateBreadcrumbs(dir string) []BreadcrumbItem { } currentPath += "/" + part - // Special handling for bucket paths - displayName := part - if len(breadcrumbs) == 1 && part == "buckets" { - displayName = "Object Store Buckets" - } else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/buckets/") { - displayName = "📦 " + part // Add bucket icon to bucket name - } + // Special handling for bucket paths + displayName := part + if len(breadcrumbs) == 1 && part == "buckets" { + displayName = "Object Store Buckets" + } else if len(breadcrumbs) == 1 && part == "table-buckets" { + displayName = "Table Buckets" + } else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/buckets/") { + displayName = "📦 " + part // Add bucket icon to bucket name + } else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/table-buckets/") { + displayName = "🧊 " + part + } breadcrumbs = append(breadcrumbs, BreadcrumbItem{ Name: displayName, diff --git a/weed/admin/dash/file_browser_data_test.go b/weed/admin/dash/file_browser_data_test.go index 0605735af..e02465034 100644 --- a/weed/admin/dash/file_browser_data_test.go +++ b/weed/admin/dash/file_browser_data_test.go @@ -51,6 +51,15 @@ func TestGenerateBreadcrumbs(t *testing.T) { {Name: "📦 mybucket", Path: "/buckets/mybucket"}, }, }, + { + name: "table bucket path", + path: "/table-buckets/mytablebucket", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + {Name: "Table Buckets", Path: "/table-buckets"}, + {Name: "🧊 mytablebucket", Path: "/table-buckets/mytablebucket"}, + }, + }, { name: "bucket nested path", path: "/buckets/mybucket/folder", @@ -61,6 +70,16 @@ func TestGenerateBreadcrumbs(t *testing.T) { {Name: "folder", Path: "/buckets/mybucket/folder"}, }, }, + { + name: "table bucket nested path", + path: "/table-buckets/mytablebucket/folder", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + {Name: "Table Buckets", Path: "/table-buckets"}, + {Name: "🧊 mytablebucket", Path: "/table-buckets/mytablebucket"}, + {Name: "folder", Path: "/table-buckets/mytablebucket/folder"}, + }, + }, { name: "path with trailing slash", path: "/folder/", @@ -176,6 +195,11 @@ func TestParentPathCalculationLogic(t *testing.T) { currentDir: "/buckets/mybucket", expected: "/buckets", }, + { + name: "table bucket directory", + currentDir: "/table-buckets/mytablebucket", + expected: "/table-buckets", + }, } for _, tt := range tests { diff --git a/weed/admin/dash/s3tables_management.go b/weed/admin/dash/s3tables_management.go new file mode 100644 index 000000000..bf9bd7c98 --- /dev/null +++ b/weed/admin/dash/s3tables_management.go @@ -0,0 +1,605 @@ +package dash + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" +) + +// S3Tables data structures for admin UI + +type S3TablesBucketsData struct { + Username string `json:"username"` + Buckets []S3TablesBucketSummary `json:"buckets"` + TotalBuckets int `json:"total_buckets"` + LastUpdated time.Time `json:"last_updated"` +} + +type S3TablesBucketSummary struct { + ARN string `json:"arn"` + Name string `json:"name"` + OwnerAccountID string `json:"ownerAccountId"` + CreatedAt time.Time `json:"createdAt"` +} + +type S3TablesNamespacesData struct { + Username string `json:"username"` + BucketARN string `json:"bucket_arn"` + Namespaces []s3tables.NamespaceSummary `json:"namespaces"` + TotalNamespaces int `json:"total_namespaces"` + LastUpdated time.Time `json:"last_updated"` +} + +type S3TablesTablesData struct { + Username string `json:"username"` + BucketARN string `json:"bucket_arn"` + Namespace string `json:"namespace"` + Tables []s3tables.TableSummary `json:"tables"` + TotalTables int `json:"total_tables"` + LastUpdated time.Time `json:"last_updated"` +} + +type tableBucketMetadata struct { + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + OwnerAccountID string `json:"ownerAccountId"` +} + +// S3Tables manager helpers + +const s3TablesAdminListLimit = 1000 + +func newS3TablesManager() *s3tables.Manager { + manager := s3tables.NewManager() + manager.SetAccountID(s3_constants.AccountAdminId) + return manager +} + +func (s *AdminServer) executeS3TablesOperation(ctx context.Context, operation string, req interface{}, resp interface{}) error { + return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + mgrClient := s3tables.NewManagerClient(client) + return s.s3TablesManager.Execute(ctx, mgrClient, operation, req, resp, s3_constants.AccountAdminId) + }) +} + +// S3Tables data retrieval for pages + +func (s *AdminServer) GetS3TablesBucketsData(ctx context.Context) (S3TablesBucketsData, error) { + var buckets []S3TablesBucketSummary + err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{ + Directory: s3tables.TablesPath, + Limit: uint32(s3TablesAdminListLimit * 2), + InclusiveStartFrom: true, + }) + if err != nil { + return err + } + for len(buckets) < s3TablesAdminListLimit { + entry, recvErr := resp.Recv() + if recvErr != nil { + if recvErr == io.EOF { + break + } + return recvErr + } + if entry.Entry == nil || !entry.Entry.IsDirectory { + continue + } + if strings.HasPrefix(entry.Entry.Name, ".") { + continue + } + metaBytes, ok := entry.Entry.Extended[s3tables.ExtendedKeyMetadata] + if !ok { + continue + } + var metadata tableBucketMetadata + if err := json.Unmarshal(metaBytes, &metadata); err != nil { + glog.V(1).Infof("S3Tables: failed to decode table bucket metadata for %s: %v", entry.Entry.Name, err) + continue + } + arn, err := s3tables.BuildBucketARN(s3tables.DefaultRegion, metadata.OwnerAccountID, entry.Entry.Name) + if err != nil { + glog.V(1).Infof("S3Tables: failed to build table bucket ARN for %s: %v", entry.Entry.Name, err) + continue + } + buckets = append(buckets, S3TablesBucketSummary{ + ARN: arn, + Name: entry.Entry.Name, + OwnerAccountID: metadata.OwnerAccountID, + CreatedAt: metadata.CreatedAt, + }) + } + return nil + }) + if err != nil { + return S3TablesBucketsData{}, err + } + return S3TablesBucketsData{ + Buckets: buckets, + TotalBuckets: len(buckets), + LastUpdated: time.Now(), + }, nil +} + +func (s *AdminServer) GetS3TablesNamespacesData(ctx context.Context, bucketArn string) (S3TablesNamespacesData, error) { + var resp s3tables.ListNamespacesResponse + req := &s3tables.ListNamespacesRequest{TableBucketARN: bucketArn, MaxNamespaces: s3TablesAdminListLimit} + if err := s.executeS3TablesOperation(ctx, "ListNamespaces", req, &resp); err != nil { + return S3TablesNamespacesData{}, err + } + return S3TablesNamespacesData{ + BucketARN: bucketArn, + Namespaces: resp.Namespaces, + TotalNamespaces: len(resp.Namespaces), + LastUpdated: time.Now(), + }, nil +} + +func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, namespace string) (S3TablesTablesData, error) { + var resp s3tables.ListTablesResponse + var ns []string + if namespace != "" { + ns = []string{namespace} + } + req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit} + if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil { + return S3TablesTablesData{}, err + } + return S3TablesTablesData{ + BucketARN: bucketArn, + Namespace: namespace, + Tables: resp.Tables, + TotalTables: len(resp.Tables), + LastUpdated: time.Now(), + }, nil +} + +// API handlers + +func (s *AdminServer) ListS3TablesBucketsAPI(c *gin.Context) { + data, err := s.GetS3TablesBucketsData(c.Request.Context()) + if err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, data) +} + +func (s *AdminServer) CreateS3TablesBucket(c *gin.Context) { + var req struct { + Name string `json:"name"` + Tags map[string]string `json:"tags"` + Owner string `json:"owner"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.Name == "" { + c.JSON(400, gin.H{"error": "Bucket name is required"}) + return + } + owner := strings.TrimSpace(req.Owner) + if len(owner) > MaxOwnerNameLength { + c.JSON(400, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)}) + return + } + if len(req.Tags) > 0 { + if err := s3tables.ValidateTags(req.Tags); err != nil { + c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) + return + } + } + createReq := &s3tables.CreateTableBucketRequest{Name: req.Name, Tags: req.Tags} + var resp s3tables.CreateTableBucketResponse + if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTableBucket", createReq, &resp); err != nil { + writeS3TablesError(c, err) + return + } + if owner != "" { + if err := s.SetTableBucketOwner(c.Request.Context(), req.Name, owner); err != nil { + deleteReq := &s3tables.DeleteTableBucketRequest{TableBucketARN: resp.ARN} + if deleteErr := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", deleteReq, nil); deleteErr != nil { + c.JSON(500, gin.H{"error": fmt.Sprintf("Failed to set table bucket owner: %v; rollback delete failed: %v", err, deleteErr)}) + return + } + writeS3TablesError(c, err) + return + } + } + c.JSON(201, gin.H{"arn": resp.ARN}) +} + +func (s *AdminServer) SetTableBucketOwner(ctx context.Context, bucketName, owner string) error { + return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{ + Directory: s3tables.TablesPath, + Name: bucketName, + }) + if err != nil { + return fmt.Errorf("lookup table bucket %s: %w", bucketName, err) + } + if resp.Entry == nil { + return fmt.Errorf("table bucket %s not found", bucketName) + } + entry := resp.Entry + if entry.Extended == nil { + return fmt.Errorf("table bucket %s metadata missing", bucketName) + } + metaBytes, ok := entry.Extended[s3tables.ExtendedKeyMetadata] + if !ok { + return fmt.Errorf("table bucket %s metadata missing", bucketName) + } + var metadata tableBucketMetadata + if err := json.Unmarshal(metaBytes, &metadata); err != nil { + return fmt.Errorf("failed to parse table bucket metadata: %w", err) + } + metadata.OwnerAccountID = owner + updated, err := json.Marshal(&metadata) + if err != nil { + return fmt.Errorf("failed to marshal table bucket metadata: %w", err) + } + entry.Extended[s3tables.ExtendedKeyMetadata] = updated + if _, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{ + Directory: s3tables.TablesPath, + Entry: entry, + }); err != nil { + return fmt.Errorf("failed to update table bucket owner: %w", err) + } + return nil + }) +} + +func (s *AdminServer) DeleteS3TablesBucket(c *gin.Context) { + bucketArn := c.Query("bucket") + if bucketArn == "" { + c.JSON(400, gin.H{"error": "Bucket ARN is required"}) + return + } + req := &s3tables.DeleteTableBucketRequest{TableBucketARN: bucketArn} + if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", req, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Bucket deleted"}) +} + +func (s *AdminServer) ListS3TablesNamespacesAPI(c *gin.Context) { + bucketArn := c.Query("bucket") + if bucketArn == "" { + c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + return + } + data, err := s.GetS3TablesNamespacesData(c.Request.Context(), bucketArn) + if err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, data) +} + +func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) { + var req struct { + BucketARN string `json:"bucket_arn"` + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.BucketARN == "" || req.Name == "" { + c.JSON(400, gin.H{"error": "bucket_arn and name are required"}) + return + } + createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Name}} + var resp s3tables.CreateNamespaceResponse + if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(201, gin.H{"namespace": resp.Namespace}) +} + +func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) { + bucketArn := c.Query("bucket") + namespace := c.Query("name") + if bucketArn == "" || namespace == "" { + c.JSON(400, gin.H{"error": "bucket and name query parameters are required"}) + return + } + req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}} + if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Namespace deleted"}) +} + +func (s *AdminServer) ListS3TablesTablesAPI(c *gin.Context) { + bucketArn := c.Query("bucket") + if bucketArn == "" { + c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + return + } + namespace := c.Query("namespace") + data, err := s.GetS3TablesTablesData(c.Request.Context(), bucketArn, namespace) + if err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, data) +} + +func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { + var req struct { + BucketARN string `json:"bucket_arn"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Format string `json:"format"` + Tags map[string]string `json:"tags"` + Metadata *s3tables.TableMetadata `json:"metadata"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.BucketARN == "" || req.Namespace == "" || req.Name == "" { + c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"}) + return + } + format := req.Format + if format == "" { + format = "ICEBERG" + } + if len(req.Tags) > 0 { + if err := s3tables.ValidateTags(req.Tags); err != nil { + c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) + return + } + } + createReq := &s3tables.CreateTableRequest{ + TableBucketARN: req.BucketARN, + Namespace: []string{req.Namespace}, + Name: req.Name, + Format: format, + Tags: req.Tags, + Metadata: req.Metadata, + } + var resp s3tables.CreateTableResponse + if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTable", createReq, &resp); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(201, gin.H{"table_arn": resp.TableARN, "version_token": resp.VersionToken}) +} + +func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) { + bucketArn := c.Query("bucket") + namespace := c.Query("namespace") + name := c.Query("name") + version := c.Query("version") + if bucketArn == "" || namespace == "" || name == "" { + c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) + return + } + req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name, VersionToken: version} + if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Table deleted"}) +} + +func (s *AdminServer) PutS3TablesBucketPolicy(c *gin.Context) { + var req struct { + BucketARN string `json:"bucket_arn"` + Policy string `json:"policy"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.BucketARN == "" || req.Policy == "" { + c.JSON(400, gin.H{"error": "bucket_arn and policy are required"}) + return + } + putReq := &s3tables.PutTableBucketPolicyRequest{TableBucketARN: req.BucketARN, ResourcePolicy: req.Policy} + if err := s.executeS3TablesOperation(c.Request.Context(), "PutTableBucketPolicy", putReq, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Policy updated"}) +} + +func (s *AdminServer) GetS3TablesBucketPolicy(c *gin.Context) { + bucketArn := c.Query("bucket") + if bucketArn == "" { + c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + return + } + getReq := &s3tables.GetTableBucketPolicyRequest{TableBucketARN: bucketArn} + var resp s3tables.GetTableBucketPolicyResponse + if err := s.executeS3TablesOperation(c.Request.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"policy": resp.ResourcePolicy}) +} + +func (s *AdminServer) DeleteS3TablesBucketPolicy(c *gin.Context) { + bucketArn := c.Query("bucket") + if bucketArn == "" { + c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + return + } + deleteReq := &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: bucketArn} + if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucketPolicy", deleteReq, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Policy deleted"}) +} + +func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) { + var req struct { + BucketARN string `json:"bucket_arn"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Policy string `json:"policy"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.BucketARN == "" || req.Namespace == "" || req.Name == "" || req.Policy == "" { + c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"}) + return + } + putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Namespace}, Name: req.Name, ResourcePolicy: req.Policy} + if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Policy updated"}) +} + +func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) { + bucketArn := c.Query("bucket") + namespace := c.Query("namespace") + name := c.Query("name") + if bucketArn == "" || namespace == "" || name == "" { + c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) + return + } + getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name} + var resp s3tables.GetTablePolicyResponse + if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"policy": resp.ResourcePolicy}) +} + +func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) { + bucketArn := c.Query("bucket") + namespace := c.Query("namespace") + name := c.Query("name") + if bucketArn == "" || namespace == "" || name == "" { + c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) + return + } + deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name} + if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Policy deleted"}) +} + +func (s *AdminServer) TagS3TablesResource(c *gin.Context) { + var req struct { + ResourceARN string `json:"resource_arn"` + Tags map[string]string `json:"tags"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.ResourceARN == "" || len(req.Tags) == 0 { + c.JSON(400, gin.H{"error": "resource_arn and tags are required"}) + return + } + if err := s3tables.ValidateTags(req.Tags); err != nil { + c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) + return + } + tagReq := &s3tables.TagResourceRequest{ResourceARN: req.ResourceARN, Tags: req.Tags} + if err := s.executeS3TablesOperation(c.Request.Context(), "TagResource", tagReq, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Tags updated"}) +} + +func (s *AdminServer) ListS3TablesTags(c *gin.Context) { + resourceArn := c.Query("arn") + if resourceArn == "" { + c.JSON(400, gin.H{"error": "arn query parameter is required"}) + return + } + listReq := &s3tables.ListTagsForResourceRequest{ResourceARN: resourceArn} + var resp s3tables.ListTagsForResourceResponse + if err := s.executeS3TablesOperation(c.Request.Context(), "ListTagsForResource", listReq, &resp); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, resp) +} + +func (s *AdminServer) UntagS3TablesResource(c *gin.Context) { + var req struct { + ResourceARN string `json:"resource_arn"` + TagKeys []string `json:"tag_keys"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.ResourceARN == "" || len(req.TagKeys) == 0 { + c.JSON(400, gin.H{"error": "resource_arn and tag_keys are required"}) + return + } + untagReq := &s3tables.UntagResourceRequest{ResourceARN: req.ResourceARN, TagKeys: req.TagKeys} + if err := s.executeS3TablesOperation(c.Request.Context(), "UntagResource", untagReq, nil); err != nil { + writeS3TablesError(c, err) + return + } + c.JSON(200, gin.H{"message": "Tags removed"}) +} + +func parseS3TablesErrorMessage(err error) string { + if err == nil { + return "" + } + var s3Err *s3tables.S3TablesError + if errors.As(err, &s3Err) { + if s3Err.Message != "" { + return fmt.Sprintf("%s: %s", s3Err.Type, s3Err.Message) + } + return s3Err.Type + } + return err.Error() +} + +func writeS3TablesError(c *gin.Context, err error) { + c.JSON(s3TablesErrorStatus(err), gin.H{"error": parseS3TablesErrorMessage(err)}) +} + +func s3TablesErrorStatus(err error) int { + var s3Err *s3tables.S3TablesError + if errors.As(err, &s3Err) { + switch s3Err.Type { + case s3tables.ErrCodeInvalidRequest: + return http.StatusBadRequest + case s3tables.ErrCodeNoSuchBucket, s3tables.ErrCodeNoSuchNamespace, s3tables.ErrCodeNoSuchTable, s3tables.ErrCodeNoSuchPolicy: + return http.StatusNotFound + case s3tables.ErrCodeAccessDenied: + return http.StatusForbidden + case s3tables.ErrCodeBucketAlreadyExists, s3tables.ErrCodeNamespaceAlreadyExists, s3tables.ErrCodeTableAlreadyExists, s3tables.ErrCodeConflict: + return http.StatusConflict + } + } + return http.StatusInternalServerError +} diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 52d8f9f66..3bc61864d 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -9,6 +9,8 @@ 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/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" "github.com/seaweedfs/seaweedfs/weed/stats" ) @@ -86,6 +88,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies) protected.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts) + protected.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets) + protected.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces) + protected.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables) // File browser routes protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) @@ -174,6 +179,29 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) } + // S3 Tables API routes + s3TablesApi := api.Group("/s3tables") + { + s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI) + s3TablesApi.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesBucket) + s3TablesApi.DELETE("/buckets", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucket) + s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI) + s3TablesApi.POST("/namespaces", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesNamespace) + s3TablesApi.DELETE("/namespaces", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesNamespace) + s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI) + s3TablesApi.POST("/tables", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesTable) + s3TablesApi.DELETE("/tables", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTable) + s3TablesApi.PUT("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesBucketPolicy) + s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy) + s3TablesApi.DELETE("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucketPolicy) + s3TablesApi.PUT("/table-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesTablePolicy) + s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy) + s3TablesApi.DELETE("/table-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTablePolicy) + s3TablesApi.PUT("/tags", dash.RequireWriteAccess(), h.adminServer.TagS3TablesResource) + s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags) + s3TablesApi.DELETE("/tags", dash.RequireWriteAccess(), h.adminServer.UntagS3TablesResource) + } + // File management API routes filesApi := api.Group("/files") { @@ -228,6 +256,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) r.GET("/object-store/policies", h.policyHandlers.ShowPolicies) r.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts) + r.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets) + r.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces) + r.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables) // File browser routes r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) @@ -315,6 +346,29 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) } + // S3 Tables API routes + s3TablesApi := api.Group("/s3tables") + { + s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI) + s3TablesApi.POST("/buckets", h.adminServer.CreateS3TablesBucket) + s3TablesApi.DELETE("/buckets", h.adminServer.DeleteS3TablesBucket) + s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI) + s3TablesApi.POST("/namespaces", h.adminServer.CreateS3TablesNamespace) + s3TablesApi.DELETE("/namespaces", h.adminServer.DeleteS3TablesNamespace) + s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI) + s3TablesApi.POST("/tables", h.adminServer.CreateS3TablesTable) + s3TablesApi.DELETE("/tables", h.adminServer.DeleteS3TablesTable) + s3TablesApi.PUT("/bucket-policy", h.adminServer.PutS3TablesBucketPolicy) + s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy) + s3TablesApi.DELETE("/bucket-policy", h.adminServer.DeleteS3TablesBucketPolicy) + s3TablesApi.PUT("/table-policy", h.adminServer.PutS3TablesTablePolicy) + s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy) + s3TablesApi.DELETE("/table-policy", h.adminServer.DeleteS3TablesTablePolicy) + s3TablesApi.PUT("/tags", h.adminServer.TagS3TablesResource) + s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags) + s3TablesApi.DELETE("/tags", h.adminServer.UntagS3TablesResource) + } + // File management API routes filesApi := api.Group("/files") { @@ -398,6 +452,91 @@ func (h *AdminHandlers) ShowS3Buckets(c *gin.Context) { } } +// ShowS3TablesBuckets renders the S3 Tables buckets page +func (h *AdminHandlers) ShowS3TablesBuckets(c *gin.Context) { + username := c.GetString("username") + if username == "" { + username = "admin" + } + + data, err := h.adminServer.GetS3TablesBucketsData(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables buckets: " + err.Error()}) + return + } + data.Username = username + + c.Header("Content-Type", "text/html") + component := app.S3TablesBuckets(data) + layoutComponent := layout.Layout(c, component) + if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + } +} + +// ShowS3TablesNamespaces renders namespaces for a table bucket +func (h *AdminHandlers) ShowS3TablesNamespaces(c *gin.Context) { + username := c.GetString("username") + if username == "" { + username = "admin" + } + + bucketName := c.Param("bucket") + arn, err := buildS3TablesBucketArn(bucketName) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + data, err := h.adminServer.GetS3TablesNamespacesData(c.Request.Context(), arn) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables namespaces: " + err.Error()}) + return + } + data.Username = username + + c.Header("Content-Type", "text/html") + component := app.S3TablesNamespaces(data) + layoutComponent := layout.Layout(c, component) + if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + } +} + +// ShowS3TablesTables renders tables for a namespace +func (h *AdminHandlers) ShowS3TablesTables(c *gin.Context) { + username := c.GetString("username") + if username == "" { + username = "admin" + } + + bucketName := c.Param("bucket") + namespace := c.Param("namespace") + arn, err := buildS3TablesBucketArn(bucketName) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + data, err := h.adminServer.GetS3TablesTablesData(c.Request.Context(), arn, namespace) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables tables: " + err.Error()}) + return + } + data.Username = username + + c.Header("Content-Type", "text/html") + component := app.S3TablesTables(data) + layoutComponent := layout.Layout(c, component) + if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + } +} + +func buildS3TablesBucketArn(bucketName string) (string, error) { + return s3tables.BuildBucketARN(s3tables.DefaultRegion, s3_constants.AccountAdminId, bucketName) +} + // ShowBucketDetails returns detailed information about a specific bucket func (h *AdminHandlers) ShowBucketDetails(c *gin.Context) { bucketName := c.Param("bucket") diff --git a/weed/admin/static/js/s3tables.js b/weed/admin/static/js/s3tables.js new file mode 100644 index 000000000..4c484bce5 --- /dev/null +++ b/weed/admin/static/js/s3tables.js @@ -0,0 +1,479 @@ +/** + * Shared S3 Tables functionality for the SeaweedFS Admin Dashboard. + */ + +// Shared Modals +let s3tablesBucketDeleteModal = null; +let s3tablesBucketPolicyModal = null; +let s3tablesNamespaceDeleteModal = null; +let s3tablesTableDeleteModal = null; +let s3tablesTablePolicyModal = null; +let s3tablesTagsModal = null; + +/** + * Initialize S3 Tables Buckets Page + */ +function initS3TablesBuckets() { + s3tablesBucketDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesBucketModal')); + s3tablesBucketPolicyModal = new bootstrap.Modal(document.getElementById('s3tablesBucketPolicyModal')); + s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal')); + + const ownerSelect = document.getElementById('s3tablesBucketOwner'); + if (ownerSelect) { + document.getElementById('createS3TablesBucketModal').addEventListener('show.bs.modal', async function () { + if (ownerSelect.options.length <= 1) { + try { + const response = await fetch('/api/users'); + const data = await response.json(); + const users = data.users || []; + users.forEach(user => { + const option = document.createElement('option'); + option.value = user.username; + option.textContent = user.username; + ownerSelect.appendChild(option); + }); + } catch (error) { + console.error('Error fetching users for owner dropdown:', error); + ownerSelect.innerHTML = ''; + ownerSelect.selectedIndex = 0; + } + } + }); + } + + document.querySelectorAll('.s3tables-delete-bucket-btn').forEach(button => { + button.addEventListener('click', function () { + document.getElementById('deleteS3TablesBucketName').textContent = this.dataset.bucketName || ''; + document.getElementById('deleteS3TablesBucketModal').dataset.bucketArn = this.dataset.bucketArn || ''; + s3tablesBucketDeleteModal.show(); + }); + }); + + document.querySelectorAll('.s3tables-bucket-policy-btn').forEach(button => { + button.addEventListener('click', function () { + const bucketArn = this.dataset.bucketArn || ''; + document.getElementById('s3tablesBucketPolicyArn').value = bucketArn; + loadS3TablesBucketPolicy(bucketArn); + s3tablesBucketPolicyModal.show(); + }); + }); + + document.querySelectorAll('.s3tables-tags-btn').forEach(button => { + button.addEventListener('click', function () { + const resourceArn = this.dataset.resourceArn || ''; + openS3TablesTags(resourceArn); + }); + }); + + const createForm = document.getElementById('createS3TablesBucketForm'); + if (createForm) { + createForm.addEventListener('submit', async function (e) { + e.preventDefault(); + const name = document.getElementById('s3tablesBucketName').value.trim(); + const owner = ownerSelect.value; + const tagsInput = document.getElementById('s3tablesBucketTags').value.trim(); + const tags = parseTagsInput(tagsInput); + if (tags === null) return; + const payload = { name: name, tags: tags, owner: owner }; + + try { + const response = await fetch('/api/s3tables/buckets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to create bucket'); + return; + } + alert('Bucket created successfully'); + location.reload(); + } catch (error) { + alert('Failed to create bucket: ' + error.message); + } + }); + } + + const policyForm = document.getElementById('s3tablesBucketPolicyForm'); + if (policyForm) { + policyForm.addEventListener('submit', async function (e) { + e.preventDefault(); + const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value; + const policy = document.getElementById('s3tablesBucketPolicyText').value.trim(); + if (!policy) { + alert('Policy JSON is required'); + return; + } + try { + const response = await fetch('/api/s3tables/bucket-policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket_arn: bucketArn, policy: policy }) + }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to update policy'); + return; + } + alert('Policy updated'); + s3tablesBucketPolicyModal.hide(); + } catch (error) { + alert('Failed to update policy: ' + error.message); + } + }); + } + + const tagsForm = document.getElementById('s3tablesTagsForm'); + if (tagsForm) { + tagsForm.addEventListener('submit', async function (e) { + e.preventDefault(); + const resourceArn = document.getElementById('s3tablesTagsResourceArn').value; + const tags = parseTagsInput(document.getElementById('s3tablesTagsInput').value.trim()); + if (tags === null || Object.keys(tags).length === 0) { + alert('Please provide tags to update'); + return; + } + await updateS3TablesTags(resourceArn, tags); + }); + } +} + +/** + * Initialize S3 Tables Tables Page + */ +function initS3TablesTables() { + s3tablesTableDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesTableModal')); + s3tablesTablePolicyModal = new bootstrap.Modal(document.getElementById('s3tablesTablePolicyModal')); + s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal')); + + const dataContainer = document.getElementById('s3tables-tables-content'); + const dataBucketArn = dataContainer.dataset.bucketArn || ''; + const dataNamespace = dataContainer.dataset.namespace || ''; + + document.querySelectorAll('.s3tables-delete-table-btn').forEach(button => { + button.addEventListener('click', function () { + document.getElementById('deleteS3TablesTableName').textContent = this.dataset.tableName || ''; + document.getElementById('deleteS3TablesTableModal').dataset.tableName = this.dataset.tableName || ''; + s3tablesTableDeleteModal.show(); + }); + }); + + document.querySelectorAll('.s3tables-table-policy-btn').forEach(button => { + button.addEventListener('click', function () { + document.getElementById('s3tablesTablePolicyBucketArn').value = dataBucketArn; + document.getElementById('s3tablesTablePolicyNamespace').value = dataNamespace; + document.getElementById('s3tablesTablePolicyName').value = this.dataset.tableName || ''; + loadS3TablesTablePolicy(dataBucketArn, dataNamespace, this.dataset.tableName || ''); + s3tablesTablePolicyModal.show(); + }); + }); + + document.querySelectorAll('.s3tables-tags-btn').forEach(button => { + button.addEventListener('click', function () { + const resourceArn = this.dataset.resourceArn || ''; + openS3TablesTags(resourceArn); + }); + }); + + const createForm = document.getElementById('createS3TablesTableForm'); + if (createForm) { + createForm.addEventListener('submit', async function (e) { + e.preventDefault(); + const name = document.getElementById('s3tablesTableName').value.trim(); + const format = document.getElementById('s3tablesTableFormat').value; + const metadataText = document.getElementById('s3tablesTableMetadata').value.trim(); + const tagsInput = document.getElementById('s3tablesTableTags').value.trim(); + const tags = parseTagsInput(tagsInput); + if (tags === null) return; + let metadata = null; + if (metadataText) { + try { + metadata = JSON.parse(metadataText); + } catch (error) { + alert('Invalid metadata JSON'); + return; + } + } + const payload = { bucket_arn: dataBucketArn, namespace: dataNamespace, name: name, format: format, tags: tags }; + if (metadata) { + payload.metadata = metadata; + } + try { + const response = await fetch('/api/s3tables/tables', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to create table'); + return; + } + alert('Table created'); + location.reload(); + } catch (error) { + alert('Failed to create table: ' + error.message); + } + }); + } + + const policyForm = document.getElementById('s3tablesTablePolicyForm'); + if (policyForm) { + policyForm.addEventListener('submit', async function (e) { + e.preventDefault(); + const policy = document.getElementById('s3tablesTablePolicyText').value.trim(); + if (!policy) { + alert('Policy JSON is required'); + return; + } + try { + const response = await fetch('/api/s3tables/table-policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket_arn: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value, policy: policy }) + }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to update policy'); + return; + } + alert('Policy updated'); + s3tablesTablePolicyModal.hide(); + } catch (error) { + alert('Failed to update policy: ' + error.message); + } + }); + } + + const tagsForm = document.getElementById('s3tablesTagsForm'); + if (tagsForm) { + tagsForm.addEventListener('submit', async function (e) { + e.preventDefault(); + const resourceArn = document.getElementById('s3tablesTagsResourceArn').value; + const tags = parseTagsInput(document.getElementById('s3tablesTagsInput').value.trim()); + if (tags === null || Object.keys(tags).length === 0) { + alert('Please provide tags to update'); + return; + } + await updateS3TablesTags(resourceArn, tags); + }); + } +} + +// Global scope functions used by onclick handlers + +async function deleteS3TablesBucket() { + const bucketArn = document.getElementById('deleteS3TablesBucketModal').dataset.bucketArn; + if (!bucketArn) return; + try { + const response = await fetch(`/api/s3tables/buckets?bucket=${encodeURIComponent(bucketArn)}`, { method: 'DELETE' }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to delete bucket'); + return; + } + alert('Bucket deleted'); + location.reload(); + } catch (error) { + alert('Failed to delete bucket: ' + error.message); + } +} + +async function loadS3TablesBucketPolicy(bucketArn) { + document.getElementById('s3tablesBucketPolicyText').value = ''; + if (!bucketArn) return; + try { + const response = await fetch(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`); + const data = await response.json(); + if (response.ok && data.policy) { + document.getElementById('s3tablesBucketPolicyText').value = data.policy; + } + } catch (error) { + console.error('Failed to load bucket policy', error); + } +} + +async function deleteS3TablesBucketPolicy() { + const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value; + if (!bucketArn) return; + try { + const response = await fetch(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`, { method: 'DELETE' }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to delete policy'); + return; + } + alert('Policy deleted'); + document.getElementById('s3tablesBucketPolicyText').value = ''; + } catch (error) { + alert('Failed to delete policy: ' + error.message); + } +} + +async function deleteS3TablesTable() { + const dataContainer = document.getElementById('s3tables-tables-content'); + const dataBucketArn = dataContainer.dataset.bucketArn || ''; + const dataNamespace = dataContainer.dataset.namespace || ''; + const tableName = document.getElementById('deleteS3TablesTableModal').dataset.tableName; + const versionToken = document.getElementById('deleteS3TablesTableVersion').value.trim(); + if (!tableName) return; + const query = new URLSearchParams({ + bucket: dataBucketArn, + namespace: dataNamespace, + name: tableName + }); + if (versionToken) { + query.set('version', versionToken); + } + try { + const response = await fetch(`/api/s3tables/tables?${query.toString()}`, { method: 'DELETE' }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to delete table'); + return; + } + alert('Table deleted'); + location.reload(); + } catch (error) { + alert('Failed to delete table: ' + error.message); + } +} + +async function loadS3TablesTablePolicy(bucketArn, namespace, name) { + document.getElementById('s3tablesTablePolicyText').value = ''; + if (!bucketArn || !namespace || !name) return; + const query = new URLSearchParams({ bucket: bucketArn, namespace: namespace, name: name }); + try { + const response = await fetch(`/api/s3tables/table-policy?${query.toString()}`); + const data = await response.json(); + if (response.ok && data.policy) { + document.getElementById('s3tablesTablePolicyText').value = data.policy; + } + } catch (error) { + console.error('Failed to load table policy', error); + } +} + +async function deleteS3TablesTablePolicy() { + const dataContainer = document.getElementById('s3tables-tables-content'); + const dataBucketArn = dataContainer.dataset.bucketArn || ''; + const dataNamespace = dataContainer.dataset.namespace || ''; + const query = new URLSearchParams({ bucket: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value }); + try { + const response = await fetch(`/api/s3tables/table-policy?${query.toString()}`, { method: 'DELETE' }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to delete policy'); + return; + } + alert('Policy deleted'); + document.getElementById('s3tablesTablePolicyText').value = ''; + } catch (error) { + alert('Failed to delete policy: ' + error.message); + } +} + +function parseTagsInput(input) { + if (!input) return {}; + const tags = {}; + const maxTags = 10; + const maxKeyLength = 128; + const maxValueLength = 256; + const parts = input.split(','); + for (const part of parts) { + const trimmedPart = part.trim(); + if (!trimmedPart) continue; + const idx = trimmedPart.indexOf('='); + if (idx <= 0) { + alert('Invalid tag format. Use key=value, and key cannot be empty.'); + return null; + } + const key = trimmedPart.slice(0, idx).trim(); + const value = trimmedPart.slice(idx + 1).trim(); + if (!key) { + alert('Invalid tag format. Use key=value, and key cannot be empty.'); + return null; + } + if (key.length > maxKeyLength) { + alert(`Tag key length must be <= ${maxKeyLength}`); + return null; + } + if (value.length > maxValueLength) { + alert(`Tag value length must be <= ${maxValueLength}`); + return null; + } + tags[key] = value; + if (Object.keys(tags).length > maxTags) { + alert(`Too many tags. Max ${maxTags} tags allowed.`); + return null; + } + } + return tags; +} + +async function openS3TablesTags(resourceArn) { + if (!resourceArn) return; + document.getElementById('s3tablesTagsResourceArn').value = resourceArn; + document.getElementById('s3tablesTagsInput').value = ''; + document.getElementById('s3tablesTagsDeleteInput').value = ''; + document.getElementById('s3tablesTagsList').textContent = 'Loading...'; + s3tablesTagsModal.show(); + try { + const response = await fetch(`/api/s3tables/tags?arn=${encodeURIComponent(resourceArn)}`); + const data = await response.json(); + if (response.ok) { + document.getElementById('s3tablesTagsList').textContent = JSON.stringify(data.tags || {}, null, 2); + } else { + document.getElementById('s3tablesTagsList').textContent = data.error || 'Failed to load tags'; + } + } catch (error) { + document.getElementById('s3tablesTagsList').textContent = error.message; + } +} + +async function updateS3TablesTags(resourceArn, tags) { + try { + const response = await fetch('/api/s3tables/tags', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resource_arn: resourceArn, tags: tags }) + }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to update tags'); + return; + } + alert('Tags updated'); + openS3TablesTags(resourceArn); + } catch (error) { + alert('Failed to update tags: ' + error.message); + } +} + +async function deleteS3TablesTags() { + const resourceArn = document.getElementById('s3tablesTagsResourceArn').value; + const keysInput = document.getElementById('s3tablesTagsDeleteInput').value.trim(); + if (!resourceArn) return; + const tagKeys = keysInput.split(',').map(k => k.trim()).filter(k => k); + if (tagKeys.length === 0) { + alert('Provide tag keys to remove'); + return; + } + try { + const response = await fetch('/api/s3tables/tags', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resource_arn: resourceArn, tag_keys: tagKeys }) + }); + const data = await response.json(); + if (!response.ok) { + alert(data.error || 'Failed to remove tags'); + return; + } + alert('Tags removed'); + openS3TablesTags(resourceArn); + } catch (error) { + alert('Failed to remove tags: ' + error.message); + } +} diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ index 38af7e7b2..58c999ebc 100644 --- a/weed/admin/view/app/file_browser.templ +++ b/weed/admin/view/app/file_browser.templ @@ -16,6 +16,8 @@ templ FileBrowser(data dash.FileBrowserData) {