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
This commit is contained in:
Chris Lu
2026-07-11 11:31:30 -07:00
committed by GitHub
parent 29981f8d24
commit 40ee4a08c5
6 changed files with 616 additions and 197 deletions
+100 -13
View File
@@ -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
+17
View File
@@ -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
+24
View File
@@ -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
+1
View File
@@ -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)
+207 -1
View File
@@ -150,6 +150,7 @@ templ S3Buckets(data dash.S3BucketsData) {
<th>Quota</th>
<th>Versioning</th>
<th>Object Lock</th>
<th>Lifecycle</th>
<th>Actions</th>
</tr>
</thead>
@@ -232,6 +233,28 @@ templ S3Buckets(data dash.S3BucketsData) {
<span class="text-muted">Not configured</span>
}
</td>
<td>
if bucket.LifecycleRuleCount > 0 {
<div>
<button type="button" class="badge bg-primary border-0 lifecycle-btn"
data-bucket-name={bucket.Name}
title="View Lifecycle Rules">
if bucket.LifecycleRuleCount == 1 {
1 rule
} else {
{fmt.Sprintf("%d rules", bucket.LifecycleRuleCount)}
}
</button>
if bucket.LifecycleEnabledCount < bucket.LifecycleRuleCount {
<div class="small text-muted">
{fmt.Sprintf("%d enabled", bucket.LifecycleEnabledCount)}
</div>
}
</div>
} else {
<span class="text-muted">Not configured</span>
}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href={dash.PUrl(ctx, fmt.Sprintf("/files?path=/buckets/%s", bucket.Name))}
@@ -239,12 +262,18 @@ templ S3Buckets(data dash.S3BucketsData) {
title="Browse Files">
<i class="fas fa-folder-open"></i>
</a>
<button type="button"
<button type="button"
class="btn btn-outline-primary btn-sm view-details-btn"
data-bucket-name={bucket.Name}
title="View Details">
<i class="fas fa-eye"></i>
</button>
<button type="button"
class="btn btn-outline-secondary btn-sm lifecycle-btn"
data-bucket-name={bucket.Name}
title="Lifecycle Rules">
<i class="fas fa-recycle"></i>
</button>
<button type="button"
class="btn btn-outline-info btn-sm owner-btn"
data-bucket-name={bucket.Name}
@@ -606,6 +635,26 @@ templ S3Buckets(data dash.S3BucketsData) {
</div>
</div>
<!-- Bucket Lifecycle Modal -->
<div class="modal fade" id="bucketLifecycleModal" tabindex="-1" aria-labelledby="bucketLifecycleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bucketLifecycleModalLabel">
<i class="fas fa-recycle me-2"></i>Lifecycle
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="bucketLifecycleContent"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Manage Owner Modal -->
<div class="modal fade" id="manageOwnerModal" tabindex="-1" aria-labelledby="manageOwnerModalLabel" aria-hidden="true">
<div class="modal-dialog">
@@ -659,6 +708,8 @@ templ S3Buckets(data dash.S3BucketsData) {
let quotaModalInstance = null;
let ownerModalInstance = null;
let detailsModalInstance = null;
let lifecycleModalInstance = null;
let lifecycleRequestSeq = 0;
let cachedUsers = null;
document.addEventListener('DOMContentLoaded', function() {
@@ -676,6 +727,7 @@ templ S3Buckets(data dash.S3BucketsData) {
quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
ownerModalInstance = new bootstrap.Modal(document.getElementById('manageOwnerModal'));
detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
lifecycleModalInstance = new bootstrap.Modal(document.getElementById('bucketLifecycleModal'));
const quotaCheckbox = document.getElementById('enableQuota');
const quotaSettings = document.getElementById('quotaSettings');
@@ -1016,6 +1068,52 @@ templ S3Buckets(data dash.S3BucketsData) {
});
});
});
// Lifecycle buttons: column badge and action button
document.querySelectorAll('.lifecycle-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
document.getElementById('bucketLifecycleModalLabel').innerHTML =
'<i class="fas fa-recycle me-2"></i>Lifecycle - ' + bucketName;
document.getElementById('bucketLifecycleContent').innerHTML =
'<div class="text-center py-4">' +
'<div class="spinner-border text-primary" role="status">' +
'<span class="visually-hidden">Loading...</span>' +
'<\/div>' +
'<div class="mt-2">Loading lifecycle rules...</div>' +
'<\/div>';
lifecycleModalInstance.show();
// Drop responses that arrive after another bucket was opened
const requestSeq = ++lifecycleRequestSeq;
fetch(basePath('/api/s3/buckets/' + bucketName + '/lifecycle'))
.then(response => response.json())
.then(data => {
if (requestSeq !== lifecycleRequestSeq) return;
if (data.error) {
document.getElementById('bucketLifecycleContent').innerHTML =
'<div class="alert alert-danger">' +
'<i class="fas fa-exclamation-triangle me-2"></i>' +
'Error loading lifecycle rules: ' + data.error +
'<\/div>';
} else {
displayBucketLifecycle(data);
}
})
.catch(error => {
if (requestSeq !== lifecycleRequestSeq) return;
console.error('Error fetching bucket lifecycle:', error);
document.getElementById('bucketLifecycleContent').innerHTML =
'<div class="alert alert-danger">' +
'<i class="fas fa-exclamation-triangle me-2"></i>' +
'Error loading lifecycle rules: ' + error.message +
'<\/div>';
});
});
});
});
function deleteBucket() {
@@ -1134,6 +1232,114 @@ function displayBucketDetails(data) {
document.getElementById('bucketDetailsContent').innerHTML = rows.join('');
}
function displayBucketLifecycle(data) {
function escapeHtml(v) {
return String(v ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function days(n) {
return n + (n === 1 ? ' day' : ' days');
}
const rules = data.rules || [];
if (rules.length === 0) {
document.getElementById('bucketLifecycleContent').innerHTML =
'<div class="text-center text-muted py-4">No lifecycle configuration<\/div>';
return;
}
function describeScope(rule) {
const parts = [];
if (rule.prefix) {
parts.push('<code>' + escapeHtml(rule.prefix) + '<\/code>');
}
Object.entries(rule.tags || {}).forEach(([k, v]) => {
parts.push('<span class="badge bg-light text-dark border">' + escapeHtml(k) + '=' + escapeHtml(v) + '<\/span>');
});
if (rule.size_greater_than) {
parts.push('size &gt; ' + formatBytes(rule.size_greater_than));
}
if (rule.size_less_than) {
parts.push('size &lt; ' + formatBytes(rule.size_less_than));
}
return parts.length ? parts.join(' ') : '<span class="text-muted">Whole bucket<\/span>';
}
function describeActions(rule) {
const actions = [];
if (rule.expiration_days) {
actions.push('Expire after ' + days(rule.expiration_days));
}
if (rule.expiration_date) {
actions.push('Expire on ' + escapeHtml(rule.expiration_date));
}
if (rule.expired_object_delete_marker) {
actions.push('Remove expired object delete markers');
}
if (rule.noncurrent_version_expiration_days) {
let action = 'Expire noncurrent versions after ' + days(rule.noncurrent_version_expiration_days);
if (rule.newer_noncurrent_versions) {
action += ', keep ' + rule.newer_noncurrent_versions + ' newest';
}
actions.push(action);
}
if (rule.abort_multipart_days) {
actions.push('Abort incomplete multipart uploads after ' + days(rule.abort_multipart_days));
}
if (actions.length === 0) {
return '<span class="text-muted">None<\/span>';
}
return actions.join('<br>');
}
const rows = [
'<div class="table-responsive">',
'<table class="table table-sm">',
'<thead><tr><th>Rule<\/th><th>Status<\/th><th>Scope<\/th><th>Actions<\/th><\/tr><\/thead>',
'<tbody>'
];
rules.forEach(rule => {
const statusHtml = rule.status === 'Enabled'
? '<span class="badge bg-success">Enabled<\/span>'
: '<span class="badge bg-secondary">' + escapeHtml(rule.status) + '<\/span>';
rows.push(
'<tr>' +
'<td>' + (rule.id ? escapeHtml(rule.id) : '<span class="text-muted">unnamed<\/span>') + '<\/td>' +
'<td>' + statusHtml + '<\/td>' +
'<td>' + describeScope(rule) + '<\/td>' +
'<td>' + describeActions(rule) + '<\/td>' +
'<\/tr>'
);
});
rows.push('<\/tbody>', '<\/table>', '<\/div>');
if (data.xml) {
rows.push(
'<details class="mt-2">',
'<summary class="text-muted small">Raw XML<\/summary>',
'<pre class="bg-light border rounded p-2 mt-2 small mb-0">' + escapeHtml(data.xml) + '<\/pre>',
'<\/details>'
);
}
document.getElementById('bucketLifecycleContent').innerHTML = rows.join('');
}
function goToPage(page) {
const url = new URL(window.location);
url.searchParams.set('page', page);
File diff suppressed because one or more lines are too long