mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-07-20 06:52:24 +00:00
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:
+100
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 > ' + formatBytes(rule.size_greater_than));
|
||||
}
|
||||
if (rule.size_less_than) {
|
||||
parts.push('size < ' + 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
Reference in New Issue
Block a user