From 40ee4a08c5b75980d316cb5dc3fdff19ce2cefa3 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 11 Jul 2026 11:31:30 -0700 Subject: [PATCH] admin: show bucket lifecycle rules in the Admin UI (#10313) * admin: surface lifecycle rule counts in bucket listing * admin: add bucket lifecycle JSON endpoint * admin: show lifecycle rules on the buckets page * admin: make the lifecycle count badge keyboard-accessible * admin: match buckets empty-state colspan to the column count * admin: drop stale lifecycle modal responses * make --- weed/admin/dash/admin_server.go | 113 +++++- weed/admin/dash/bucket_management.go | 17 + weed/admin/dash/types.go | 24 ++ weed/admin/handlers/admin_handlers.go | 1 + weed/admin/view/app/s3_buckets.templ | 208 ++++++++++- weed/admin/view/app/s3_buckets_templ.go | 450 ++++++++++++++---------- 6 files changed, 616 insertions(+), 197 deletions(-) diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 4adbc2178..4f2489379 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -33,7 +33,10 @@ import ( "google.golang.org/grpc" "github.com/seaweedfs/seaweedfs/weed/s3api" + "github.com/seaweedfs/seaweedfs/weed/s3api/lifecycle_xml" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler" "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" @@ -797,20 +800,23 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { lastModified = time.Unix(resp.Entry.Attributes.Mtime, 0) } readOnly := fc.MatchStorageRule(filerConfig.BucketsPath + "/" + bucketName + "/").ReadOnly + lifecycleRuleCount, lifecycleEnabledCount := extractLifecycleCountsFromEntry(resp.Entry) bucket := S3Bucket{ - Name: bucketName, - CreatedAt: createdAt, - LogicalSize: logicalSize, - PhysicalSize: physicalSize, - LastModified: lastModified, - Quota: quota, - QuotaEnabled: quotaEnabled, - ReadOnly: readOnly, - VersioningStatus: versioningStatus, - ObjectLockEnabled: objectLockEnabled, - ObjectLockMode: objectLockMode, - ObjectLockDuration: objectLockDuration, - Owner: owner, + Name: bucketName, + CreatedAt: createdAt, + LogicalSize: logicalSize, + PhysicalSize: physicalSize, + LastModified: lastModified, + Quota: quota, + QuotaEnabled: quotaEnabled, + ReadOnly: readOnly, + VersioningStatus: versioningStatus, + ObjectLockEnabled: objectLockEnabled, + ObjectLockMode: objectLockMode, + ObjectLockDuration: objectLockDuration, + Owner: owner, + LifecycleRuleCount: lifecycleRuleCount, + LifecycleEnabledCount: lifecycleEnabledCount, } buckets = append(buckets, bucket) } @@ -915,6 +921,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error details.Bucket.ObjectLockMode = objectLockMode details.Bucket.ObjectLockDuration = objectLockDuration details.Bucket.Owner = owner + details.Bucket.LifecycleRuleCount, details.Bucket.LifecycleEnabledCount = extractLifecycleCountsFromEntry(bucketResp.Entry) return nil }) @@ -926,6 +933,68 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error return details, nil } +// GetBucketLifecycle returns the lifecycle configuration stored on a bucket's filer entry +func (s *AdminServer) GetBucketLifecycle(bucketName string) (*BucketLifecycle, error) { + filerConfig, err := s.getFilerConfig() + if err != nil { + glog.Warningf("Failed to get filer configuration, using defaults: %v", err) + } + + lifecycle := &BucketLifecycle{ + Bucket: bucketName, + Rules: []BucketLifecycleRule{}, + } + + err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{ + Directory: filerConfig.BucketsPath, + Name: bucketName, + }) + if err != nil { + return fmt.Errorf("bucket not found: %w", err) + } + + xmlBytes := resp.Entry.Extended[scheduler.BucketLifecycleConfigurationXMLKey] + if len(xmlBytes) == 0 { + return nil + } + rules, err := lifecycle_xml.ParseCanonical(xmlBytes) + if err != nil { + return fmt.Errorf("parse lifecycle configuration: %w", err) + } + lifecycle.XML = string(xmlBytes) + for _, rule := range rules { + lifecycle.Rules = append(lifecycle.Rules, toBucketLifecycleRule(rule)) + } + return nil + }) + if err != nil { + return nil, err + } + + return lifecycle, nil +} + +func toBucketLifecycleRule(rule *s3lifecycle.Rule) BucketLifecycleRule { + out := BucketLifecycleRule{ + ID: rule.ID, + Status: rule.Status, + Prefix: rule.Prefix, + Tags: rule.FilterTags, + SizeGreaterThan: rule.FilterSizeGreaterThan, + SizeLessThan: rule.FilterSizeLessThan, + ExpirationDays: rule.ExpirationDays, + ExpiredObjectDeleteMarker: rule.ExpiredObjectDeleteMarker, + NoncurrentVersionExpirationDays: rule.NoncurrentVersionExpirationDays, + NewerNoncurrentVersions: rule.NewerNoncurrentVersions, + AbortMultipartDays: rule.AbortMPUDaysAfterInitiation, + } + if !rule.ExpirationDate.IsZero() { + out.ExpirationDate = rule.ExpirationDate.Format(time.DateOnly) + } + return out +} + // CreateS3Bucket creates a new S3 bucket func (s *AdminServer) CreateS3Bucket(bucketName string) error { return s.CreateS3BucketWithQuota(bucketName, 0, false) @@ -1812,6 +1881,24 @@ func extractVersioningFromEntry(entry *filer_pb.Entry) string { return s3api.GetVersioningStatus(entry) } +func extractLifecycleCountsFromEntry(entry *filer_pb.Entry) (ruleCount, enabledCount int) { + xmlBytes := entry.Extended[scheduler.BucketLifecycleConfigurationXMLKey] + if len(xmlBytes) == 0 { + return + } + rules, err := lifecycle_xml.ParseCanonical(xmlBytes) + if err != nil { + return + } + for _, rule := range rules { + ruleCount++ + if rule.Status == s3lifecycle.StatusEnabled { + enabledCount++ + } + } + return +} + // GetConfigPersistence returns the config persistence manager func (as *AdminServer) GetConfigPersistence() *ConfigPersistence { return as.configPersistence diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index 10e70e9dc..93d53331c 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -85,6 +85,23 @@ func (s *AdminServer) ShowBucketDetails(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, details) } +// ShowBucketLifecycle returns the lifecycle configuration for a specific bucket +func (s *AdminServer) ShowBucketLifecycle(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] + if bucketName == "" { + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") + return + } + + lifecycle, err := s.GetBucketLifecycle(bucketName) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to get bucket lifecycle: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, lifecycle) +} + // CreateBucket creates a new S3 bucket func (s *AdminServer) CreateBucket(w http.ResponseWriter, r *http.Request) { var req CreateBucketRequest diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index e8fcacb53..322fa89dd 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -85,6 +85,9 @@ type S3Bucket struct { ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE" ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days Owner string `json:"owner,omitempty"` // Bucket owner identity; empty means admin-only access + + LifecycleRuleCount int `json:"lifecycle_rule_count"` + LifecycleEnabledCount int `json:"lifecycle_enabled_count"` } type S3Object struct { @@ -100,6 +103,27 @@ type BucketDetails struct { UpdatedAt time.Time `json:"updated_at"` } +type BucketLifecycleRule struct { + ID string `json:"id,omitempty"` + Status string `json:"status"` + Prefix string `json:"prefix,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + SizeGreaterThan int64 `json:"size_greater_than,omitempty"` + SizeLessThan int64 `json:"size_less_than,omitempty"` + ExpirationDays int `json:"expiration_days,omitempty"` + ExpirationDate string `json:"expiration_date,omitempty"` + ExpiredObjectDeleteMarker bool `json:"expired_object_delete_marker,omitempty"` + NoncurrentVersionExpirationDays int `json:"noncurrent_version_expiration_days,omitempty"` + NewerNoncurrentVersions int `json:"newer_noncurrent_versions,omitempty"` + AbortMultipartDays int `json:"abort_multipart_days,omitempty"` +} + +type BucketLifecycle struct { + Bucket string `json:"bucket"` + Rules []BucketLifecycleRule `json:"rules"` + XML string `json:"xml,omitempty"` +} + // ObjectStoreUser is defined in admin_data.go // Volume management structures diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 1437d0c2d..842ba8b69 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -178,6 +178,7 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { s3Api.Handle("/buckets", wrapWrite(h.adminServer.CreateBucket)).Methods(http.MethodPost) s3Api.Handle("/buckets/{bucket}", wrapWrite(h.adminServer.DeleteBucket)).Methods(http.MethodDelete) s3Api.HandleFunc("/buckets/{bucket}", h.adminServer.ShowBucketDetails).Methods(http.MethodGet) + s3Api.HandleFunc("/buckets/{bucket}/lifecycle", h.adminServer.ShowBucketLifecycle).Methods(http.MethodGet) s3Api.Handle("/buckets/{bucket}/quota", wrapWrite(h.adminServer.UpdateBucketQuota)).Methods(http.MethodPut) s3Api.Handle("/buckets/{bucket}/owner", wrapWrite(h.adminServer.UpdateBucketOwner)).Methods(http.MethodPut) diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ index 218f28787..224de2adc 100644 --- a/weed/admin/view/app/s3_buckets.templ +++ b/weed/admin/view/app/s3_buckets.templ @@ -150,6 +150,7 @@ templ S3Buckets(data dash.S3BucketsData) { Quota Versioning Object Lock + Lifecycle Actions @@ -232,6 +233,28 @@ templ S3Buckets(data dash.S3BucketsData) { Not configured } + + if bucket.LifecycleRuleCount > 0 { +
+ + if bucket.LifecycleEnabledCount < bucket.LifecycleRuleCount { +
+ {fmt.Sprintf("%d enabled", bucket.LifecycleEnabledCount)} +
+ } +
+ } else { + Not configured + } +
- +
+ + +
Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, 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/s3_buckets.templ`, Line: 357, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 386, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Lifecycle
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }