mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 09:00:45 +02:00
* admin: treat a missing S3 Tables policy as an empty load, not an error
The bucket/table policy GET relayed the backend's 404 NoSuchPolicy to the
dialog, whose loader treats any non-OK response as a load failure and
keeps Save and Delete blocked. A bucket or table without a policy could
never be given one. Return policy null instead, the same contract
ShowBucketPolicy uses for classic buckets.
* admin: reject policy documents the structured editor would misread
A top-level JSON array passed the object guard (typeof [] is 'object')
and loaded as a zero-statement policy, which the next commit would
rewrite to an empty document. Object elements in Action/Resource were
coerced to '[object Object]' and saved that way on the s3tables surface,
which stores policies verbatim. Both now throw, which routes the
document to the JSON tab like other unrepresentable shapes.
* admin: let the JSON tab save documents the structured editor can't model
Save with the JSON tab active required a round-trip through
policyDocToEditorState, so exactly the documents the dialogs shunt to
'JSON tab only' mode (unrepresentable Effect, Resource+NotResource, and
the like) could never be saved - Delete was the only mutation left.
Invalid JSON still blocks; an unrepresentable document now saves and the
editor state stays marked unparsed.
* admin: pin the policy editor to what each consumer's backend supports
The s3tables evaluator has no NotResource/NotPrincipal fields - it
silently drops them, turning Allow+NotResource into allow-everything and
making Deny+NotPrincipal inert - and it only matches s3tables: actions
against s3tables ARNs, while the editor suggested s3: actions and
arn:aws:s3::: resources. New registerPolicyEditor knobs: allowNegation
hides the Not* modes and routes documents using them to the JSON tab;
resourceSuggestions pins the Resource autocomplete to the open
resource's ARN; the S3 Tables dialogs get an s3tables-only action
datalist. requirePrincipal now also hides NotPrincipal, which
policy_engine.ValidateBucketPolicy always rejects, and the client-side
check requires Principal specifically to match that server rule.
* admin: save S3 Tables policies from a button, not form submission
The multi-input structured editor sits inside a form whose Save button
was type=submit, so Enter in any single-line editor input - accepting an
autocomplete suggestion, say - implicitly submitted whatever half-built
statement the editor held, and the backend stores the document verbatim.
A lone statement with no Principal matches nobody, locking out every
non-owner. Save is now an ordinary button and the form ignores
submission.
* admin: block zero-statement policy saves
Committing the active tab before the emptiness check made 'Policy JSON
is required' dead code: an empty editor serializes to {"Statement":[]},
which the s3tables backend stores verbatim - evaluated default-deny for
every non-owner, while the statement-count column keeps showing 'Not
configured'. All three policy dialogs now refuse a save with no
statements and point at Delete instead. The classic bucket modal only
gained a clearer message; the server already rejected the document.
* admin: guard S3 Tables policy mutations against stale and overlapping requests
The save/delete completions ran against whatever resource the shared
modal happened to show by then: a slow PUT for one bucket would hide the
modal mid-edit of another and misattribute its alerts, a late DELETE
cleared the shared textarea over the newly opened resource with its
loaded flag set, and nothing stopped a double-click from firing two
overlapping mutations. Ported the classic modal's pattern: capture the
target on start, flag the mutation in flight with the buttons disabled,
and only touch the UI when the completion still matches the open
resource. Success now reloads the page, which also keeps the Policy
column's statement count honest.
* admin: confirm before deleting an S3 Tables policy
Delete Policy sat next to Save and fired on a single click; with
default-allow enabled one stray click silently dropped the resource
policy and left the bucket open to every principal. Same confirmation
the classic bucket modal already has.
* admin: let a corrupt stored bucket policy be shown, fixed, and deleted
A stored document the decoder rejects made the policy GET 500, and with
the loaded flag never set the modal blocked both Save and Delete - the
one policy an operator most needs to remove was the one they couldn't,
even though the delete path never reads the document. The GET now
returns the raw bytes alongside a null policy; the dialog hands them to
the JSON tab and unblocks the buttons.
* admin: url-encode the bucket name in the policy API calls
The filer lists any directory under the buckets path, names S3 would
never allow included; one carrying '#' or '%' broke the fetch URL or
addressed a different name than the modal shows.
* admin: drop stale edit-policy responses on the IAM policies page
The same race the bucket and S3 Tables dialogs already guard against:
open one policy's editor while its GET stalls, open another, and the
late response populates the editor under the second policy's name -
Update then saves the first policy's statements over the second.
* admin: warn before a bucket policy save drops unsupported fields
The editor tracks unmodeled top-level keys precisely so
confirmPolicyFieldDiscard can warn before the server's Version+Statement
decode discards them, but only the IAM page called it; the bucket modal
saved a pasted document with e.g. a console-generated Id without a word
while the editor kept displaying the field.
* s3: enforce the bucket policy size cap on both surfaces
The 20KB cap lived only in the admin UI, so a larger policy stored via
the S3 API displayed there but could never be re-saved, desyncing the
two writers the cap comment claimed could not desync. The constant now
lives in policy_engine next to the shared validator and PutBucketPolicy
rejects oversized documents with PolicyTooLarge, matching AWS.
* admin: ship the policy editor's fieldset styles with the editor
The .policy-stmt-* rules that undo Bootstrap's full-width legend reset
stayed behind in policies.templ when the editor markup moved to the
shared script, so the bucket and S3 Tables dialogs rendered Actions/
Resource/Principal as full-width jumbo headings. PolicyDatalists is the
component every consumer already renders once; the styles live there
now.
* s3: mirror bucket policy changes into the IAM store from the metadata subscription
The advanced-IAM path appends the bucket-policy:<bucket> document to
every STS/session evaluation, but only this gateway's own PutBucketPolicy
maintained that mirror - a policy tightened or created through the admin
UI (or another gateway) never reached it, so revoked access stayed live
indefinitely, and the delete side was an unimplemented TODO in any case.
The metadata subscription now diffs the stored policy on every bucket
entry change and updates or removes the mirror, covering all writers and
deletion with one mechanism; IAMManager gains the missing
RemoveBucketPolicy.
* admin: deduplicate the bucket policy write path
Set and Delete carried line-for-line identical filer closures;
bucketPolicyMutation already treats nil as clear-the-key. The shared
helper sits below Set's validation, since ValidatePolicy cannot take the
nil document Delete passes.
* s3: drop ValidateBucketPolicy's re-checks of ValidatePolicy rules
Both callers run ValidatePolicy first, which already enforces the
version and at-least-one-statement rules; the duplicates were dead code
with drifted error text.
* admin: seed a new statement's Resource from the pinned suggestions
A fresh statement on the S3 Tables dialogs started with no resource row
at all; seed it with the broadest pinned ARN the same way cfg.bucket
already seeds the classic modal.
* admin: refuse to save Not* fields the backend would silently drop
Hiding the NotResource/NotPrincipal modes was not enough where negation
is disallowed: the JSON tab accepts any valid document (that is its
job), and a statement's Advanced-fields box can reintroduce the keys, so
an s3tables save could still store fields the evaluator drops - turning
Allow+NotResource into allow-everything. commitPolicyActiveTab now runs
a final document-level check over what would actually be saved; Delete
stays available for cleanup.
* s3: move the IAM bucket policy mirror on a bucket rename
A same-directory rename delivers one event carrying both entries, and
the byte-equality short-circuit skipped the new name's mirror when the
policy was unchanged - while the replayed delete for the old name
removed its mirror, leaving the renamed bucket unmirrored. The mirror
decision is now a pure function that removes the old name and writes the
new one regardless of byte equality, with the rename cases unit tested.
* s3: backfill the IAM bucket policy mirror on lazy bucket loads
The metadata subscription only mirrors changes, so a policy that
predates the IAM integration never reached the bucket-policy:<bucket>
mirror and its grants did not bind on the IAM path until the policy was
next modified. The gateway is deliberately lazy at startup (nothing
lists all buckets), so the backfill hooks the same place a bucket's
policy first becomes known: the cold bucket-config load. EnsureBucketPolicy
writes only when no mirror is stored, so repeat loads cost one cached
read.
* s3: reconcile the bucket policy backfill against concurrent changes
The backfill's check-then-write could race an event-driven mirror update
or removal and re-store bytes that were already stale, with no later
event to heal it. EnsureBucketPolicy now reports whether it wrote, and a
write is reconciled against a fresh authoritative entry read: a changed
policy is re-mirrored, a removed one is removed. Anything changing after
that read fires its own event, which finds the backfill's write already
present and supersedes it. The backfill also carries the entry's raw
bytes rather than a re-marshaled document, so the reconcile can
byte-compare.
* s3: prime the bucket policy mirror before advanced-IAM authorization
The backfill ran from the lazy bucket-config load, but IAM authorization
evaluates the bucket-policy:<bucket> mirror before any handler runs - a
grant carried only by a not-yet-mirrored policy denied forever, and the
denied request never reached the code that would have loaded the bucket.
authorizeWithIAM now primes the bucket config first (an in-memory cache
hit once warm), and the backfill runs synchronously on the cold load so
the very first authorization already sees the mirror.
2287 lines
119 KiB
Templ
2287 lines
119 KiB
Templ
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
|
)
|
|
|
|
templ S3Buckets(data dash.S3BucketsData) {
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
|
<h1 class="h2">
|
|
<i class="fas fa-cube me-2"></i>Object Store Buckets
|
|
</h1>
|
|
<div class="btn-toolbar mb-2 mb-md-0">
|
|
<div class="btn-group me-2">
|
|
<select class="form-select form-select-sm me-2" id="pageSizeSelect" onchange="changePageSize()" style="width: auto;">
|
|
<option value="50" if data.PageSize == 50 { selected="selected" }>50 per page</option>
|
|
<option value="100" if data.PageSize == 100 { selected="selected" }>100 per page</option>
|
|
<option value="200" if data.PageSize == 200 { selected="selected" }>200 per page</option>
|
|
<option value="500" if data.PageSize == 500 { selected="selected" }>500 per page</option>
|
|
</select>
|
|
<button type="button" class="btn btn-sm btn-primary"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#createBucketModal">
|
|
<i class="fas fa-plus me-1"></i>Create Bucket
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="s3-buckets-content">
|
|
<!-- Summary Cards -->
|
|
<div class="row mb-4">
|
|
<div class="col-xl-4 col-md-6 mb-4">
|
|
<div class="card border-left-primary shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
|
Total Buckets
|
|
</div>
|
|
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
|
{fmt.Sprintf("%d", data.TotalBuckets)}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-cube fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-xl-4 col-md-6 mb-4">
|
|
<div class="card border-left-success shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
|
Total Storage
|
|
</div>
|
|
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
|
{formatBytes(data.TotalSize)}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-hdd fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div class="col-xl-4 col-md-6 mb-4">
|
|
<div class="card border-left-warning shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
|
Last Updated
|
|
</div>
|
|
<div class="h6 mb-0 font-weight-bold text-gray-800">
|
|
{data.LastUpdated.Format("15:04:05")}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Buckets Table -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card shadow mb-4">
|
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
|
<h6 class="m-0 font-weight-bold text-primary">
|
|
<i class="fas fa-cube me-2"></i>Object Store Buckets
|
|
</h6>
|
|
<div class="dropdown no-arrow">
|
|
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
|
<i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
|
|
</a>
|
|
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
|
|
<div class="dropdown-header">Actions:</div>
|
|
<a class="dropdown-item" href="#" onclick="exportBucketList()">
|
|
<i class="fas fa-download me-2"></i>Export List
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" width="100%" cellspacing="0" id="bucketsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>
|
|
<a href="#" onclick="sortTable('name')" class="text-decoration-none text-dark">
|
|
Name
|
|
@getSortIcon("name", data.SortBy, data.SortOrder)
|
|
</a>
|
|
</th>
|
|
<th>
|
|
<a href="#" onclick="sortTable('owner')" class="text-decoration-none text-dark">
|
|
Owner
|
|
@getSortIcon("owner", data.SortBy, data.SortOrder)
|
|
</a>
|
|
</th>
|
|
<th>
|
|
<a href="#" onclick="sortTable('created')" class="text-decoration-none text-dark">
|
|
Created
|
|
@getSortIcon("created", data.SortBy, data.SortOrder)
|
|
</a>
|
|
</th>
|
|
<th>
|
|
<a href="#" onclick="sortTable('logical_size')" class="text-decoration-none text-dark">
|
|
Logical Size
|
|
@getSortIcon("logical_size", data.SortBy, data.SortOrder)
|
|
</a>
|
|
</th>
|
|
<th>
|
|
<a href="#" onclick="sortTable('physical_size')" class="text-decoration-none text-dark">
|
|
Physical Size
|
|
@getSortIcon("physical_size", data.SortBy, data.SortOrder)
|
|
</a>
|
|
</th>
|
|
<th>Quota</th>
|
|
<th>Versioning</th>
|
|
<th>Object Lock</th>
|
|
<th>Lifecycle</th>
|
|
<th>Policy</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, bucket := range data.Buckets {
|
|
<tr>
|
|
<td>
|
|
<a href={dash.PUrl(ctx, fmt.Sprintf("/files?path=/buckets/%s", bucket.Name))}
|
|
class="text-decoration-none">
|
|
<i class="fas fa-cube me-2"></i>
|
|
{bucket.Name}
|
|
</a>
|
|
if bucket.ReadOnly {
|
|
<span class="badge bg-danger ms-2" title="Bucket is read-only (quota enforcement or manual lock); writes are rejected">
|
|
<i class="fas fa-lock me-1"></i>Read-only
|
|
</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if bucket.Owner != "" {
|
|
<span class="badge bg-info">
|
|
<i class="fas fa-user me-1"></i>{bucket.Owner}
|
|
</span>
|
|
} else {
|
|
<span class="text-muted small">No owner</span>
|
|
}
|
|
</td>
|
|
<td>{bucket.CreatedAt.Format("2006-01-02 15:04")}</td>
|
|
<td>
|
|
<div>{formatBytes(bucket.LogicalSize)}</div>
|
|
if bucket.PhysicalSize > 0 && bucket.LogicalSize > 0 && bucket.PhysicalSize > bucket.LogicalSize {
|
|
<div class="small text-muted">
|
|
{fmt.Sprintf("%.1fx overhead", float64(bucket.PhysicalSize)/float64(bucket.LogicalSize))}
|
|
</div>
|
|
}
|
|
</td>
|
|
<td>{formatBytes(bucket.PhysicalSize)}</td>
|
|
<td>
|
|
if bucket.Quota > 0 {
|
|
<div>
|
|
<span class={fmt.Sprintf("badge bg-%s", getQuotaStatusColor(bucket.LogicalSize, bucket.Quota, bucket.QuotaEnabled))}>
|
|
{formatBytes(bucket.Quota)}
|
|
</span>
|
|
if bucket.QuotaEnabled {
|
|
<div class="small text-muted">
|
|
{fmt.Sprintf("%.1f%% used", float64(bucket.LogicalSize)/float64(bucket.Quota)*100)}
|
|
</div>
|
|
} else {
|
|
<div class="small text-muted">Disabled</div>
|
|
}
|
|
</div>
|
|
} else {
|
|
<span class="text-muted">No quota</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if bucket.VersioningStatus == "Enabled" {
|
|
<span class="badge bg-success">
|
|
<i class="fas fa-check me-1"></i>Enabled
|
|
</span>
|
|
} else if bucket.VersioningStatus == "Suspended" {
|
|
<span class="badge bg-warning">
|
|
<i class="fas fa-pause me-1"></i>Suspended
|
|
</span>
|
|
} else {
|
|
<span class="text-muted">Not configured</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
if bucket.ObjectLockEnabled {
|
|
<div>
|
|
<span class="badge bg-warning">
|
|
<i class="fas fa-lock me-1"></i>Enabled
|
|
</span>
|
|
<div class="small text-muted">
|
|
{bucket.ObjectLockMode} • {fmt.Sprintf("%d days", bucket.ObjectLockDuration)}
|
|
</div>
|
|
</div>
|
|
} else {
|
|
<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>
|
|
if bucket.PolicyStatementCount > 0 {
|
|
<button type="button" class="badge bg-primary border-0 policy-btn"
|
|
data-bucket-name={bucket.Name}
|
|
title="View Bucket Policy">
|
|
if bucket.PolicyStatementCount == 1 {
|
|
1 statement
|
|
} else {
|
|
{fmt.Sprintf("%d statements", bucket.PolicyStatementCount)}
|
|
}
|
|
</button>
|
|
} 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))}
|
|
class="btn btn-outline-success btn-sm"
|
|
title="Browse Files">
|
|
<i class="fas fa-folder-open"></i>
|
|
</a>
|
|
<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 policy-btn"
|
|
data-bucket-name={bucket.Name}
|
|
title="Bucket Policy">
|
|
<i class="fas fa-shield-alt"></i>
|
|
</button>
|
|
<button type="button"
|
|
class="btn btn-outline-info btn-sm owner-btn"
|
|
data-bucket-name={bucket.Name}
|
|
data-current-owner={bucket.Owner}
|
|
title="Manage Owner">
|
|
<i class="fas fa-user-edit"></i>
|
|
</button>
|
|
<button type="button"
|
|
class="btn btn-outline-warning btn-sm quota-btn"
|
|
data-bucket-name={bucket.Name}
|
|
data-current-quota={fmt.Sprintf("%d", getQuotaInMB(bucket.Quota))}
|
|
data-quota-enabled={fmt.Sprintf("%t", bucket.QuotaEnabled)}
|
|
title="Manage Quota">
|
|
<i class="fas fa-tachometer-alt"></i>
|
|
</button>
|
|
<button type="button"
|
|
class="btn btn-outline-danger btn-sm delete-bucket-btn"
|
|
data-bucket-name={bucket.Name}
|
|
title="Delete Bucket">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
if len(data.Buckets) == 0 {
|
|
<tr>
|
|
<td colspan="11" class="text-center text-muted py-4">
|
|
<i class="fas fa-cube fa-3x mb-3 text-muted"></i>
|
|
<div>
|
|
<h5>No Object Store buckets found</h5>
|
|
<p>Create your first bucket to get started with S3 storage.</p>
|
|
<button type="button" class="btn btn-primary"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#createBucketModal">
|
|
<i class="fas fa-plus me-1"></i>Create Bucket
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination Controls -->
|
|
if data.TotalPages > 1 {
|
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
|
<small class="text-muted">
|
|
Showing { fmt.Sprintf("%d", (data.CurrentPage-1)*data.PageSize+1) } to { fmt.Sprintf("%d", minInt(data.CurrentPage*data.PageSize, data.TotalBuckets)) } of { fmt.Sprintf("%d", data.TotalBuckets) } buckets
|
|
</small>
|
|
<nav aria-label="Buckets pagination">
|
|
<ul class="pagination pagination-sm mb-0">
|
|
<!-- Previous Button -->
|
|
if data.CurrentPage > 1 {
|
|
<li class="page-item">
|
|
<a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", data.CurrentPage-1)}>
|
|
<i class="fas fa-chevron-left"></i>
|
|
</a>
|
|
</li>
|
|
} else {
|
|
<li class="page-item disabled">
|
|
<span class="page-link">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</span>
|
|
</li>
|
|
}
|
|
|
|
<!-- Page Numbers -->
|
|
for i := maxInt(1, data.CurrentPage-2); i <= minInt(data.TotalPages, data.CurrentPage+2); i++ {
|
|
if i == data.CurrentPage {
|
|
<li class="page-item active">
|
|
<span class="page-link">{fmt.Sprintf("%d", i)}</span>
|
|
</li>
|
|
} else {
|
|
<li class="page-item">
|
|
<a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", i)}>{fmt.Sprintf("%d", i)}</a>
|
|
</li>
|
|
}
|
|
}
|
|
|
|
<!-- Next Button -->
|
|
if data.CurrentPage < data.TotalPages {
|
|
<li class="page-item">
|
|
<a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", data.CurrentPage+1)}>
|
|
<i class="fas fa-chevron-right"></i>
|
|
</a>
|
|
</li>
|
|
} else {
|
|
<li class="page-item disabled">
|
|
<span class="page-link">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last Updated -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<small class="text-muted">
|
|
<i class="fas fa-clock me-1"></i>
|
|
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Bucket Modal -->
|
|
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-labelledby="createBucketModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="createBucketModalLabel">
|
|
<i class="fas fa-plus me-2"></i>Create New S3 Bucket
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form id="createBucketForm">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label for="bucketName" class="form-label">Bucket Name</label>
|
|
<input type="text" class="form-control" id="bucketName" name="name"
|
|
placeholder="my-bucket-name" required
|
|
pattern="[a-z0-9.-]+"
|
|
title="Bucket name must contain only lowercase letters, numbers, dots, and hyphens">
|
|
<div class="form-text">
|
|
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="bucketOwner" class="form-label">Owner (Optional)</label>
|
|
<select class="form-select" id="bucketOwner" name="owner">
|
|
<option value="">No owner (admin-only access)</option>
|
|
<!-- Options will be populated dynamically when modal opens -->
|
|
</select>
|
|
<div class="form-text">
|
|
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="enableQuota" name="quota_enabled">
|
|
<label class="form-check-label" for="enableQuota">
|
|
Enable Storage Quota
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3" id="quotaSettings" style="display: none;">
|
|
<div class="row">
|
|
<div class="col-md-8">
|
|
<label for="quotaSize" class="form-label">Quota Size</label>
|
|
<input type="number" class="form-control" id="quotaSize" name="quota_size"
|
|
placeholder="1024" min="1" step="1">
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="quotaUnit" class="form-label">Unit</label>
|
|
<select class="form-select" id="quotaUnit" name="quota_unit">
|
|
<option value="MB" selected>MB</option>
|
|
<option value="GB">GB</option>
|
|
<option value="TB">TB</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-text">
|
|
Set the maximum storage size for this bucket.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="enableVersioning" name="versioning_enabled">
|
|
<label class="form-check-label" for="enableVersioning">
|
|
Enable Object Versioning
|
|
</label>
|
|
</div>
|
|
<div class="form-text">
|
|
Keep multiple versions of objects in this bucket.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="enableObjectLock" name="object_lock_enabled">
|
|
<label class="form-check-label" for="enableObjectLock">
|
|
Enable Object Lock
|
|
</label>
|
|
</div>
|
|
<div class="form-text">
|
|
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3" id="objectLockSettings" style="display: none;">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<label for="objectLockMode" class="form-label">Object Lock Mode</label>
|
|
<select class="form-select" id="objectLockMode" name="object_lock_mode">
|
|
<option value="GOVERNANCE" selected>Governance</option>
|
|
<option value="COMPLIANCE">Compliance</option>
|
|
</select>
|
|
<div class="form-text">
|
|
Governance allows override with special permissions, Compliance is immutable.
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="form-check mb-3">
|
|
<input class="form-check-input" type="checkbox" id="setDefaultRetention" name="set_default_retention">
|
|
<label class="form-check-label" for="setDefaultRetention">
|
|
Set Default Retention
|
|
</label>
|
|
<div class="form-text">
|
|
Apply default retention to all new objects in this bucket.
|
|
</div>
|
|
</div>
|
|
<div id="defaultRetentionSettings" style="display: none;">
|
|
<label for="objectLockDuration" class="form-label">Default Retention (days)</label>
|
|
<input type="number" class="form-control" id="objectLockDuration" name="object_lock_duration"
|
|
placeholder="30" min="1" max="36500" step="1">
|
|
<div class="form-text">
|
|
Default retention period for new objects (1-36500 days).
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="fas fa-plus me-1"></i>Create Bucket
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<div class="modal fade" id="deleteBucketModal" tabindex="-1" aria-labelledby="deleteBucketModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="deleteBucketModalLabel">
|
|
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>Delete Bucket
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Are you sure you want to delete the bucket <strong id="deleteBucketName"></strong>?</p>
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
<strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-danger" onclick="deleteBucket()">
|
|
<i class="fas fa-trash me-1"></i>Delete Bucket
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manage Quota Modal -->
|
|
<div class="modal fade" id="manageQuotaModal" tabindex="-1" aria-labelledby="manageQuotaModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="manageQuotaModalLabel">
|
|
<i class="fas fa-tachometer-alt me-2"></i>Manage Bucket Quota
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form id="quotaForm">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Bucket Name</label>
|
|
<input type="text" class="form-control" id="quotaBucketName" readonly>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="quotaEnabled" name="quota_enabled">
|
|
<label class="form-check-label" for="quotaEnabled">
|
|
Enable Storage Quota
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3" id="quotaSizeSettings">
|
|
<div class="row">
|
|
<div class="col-md-8">
|
|
<label for="quotaSizeMB" class="form-label">Quota Size</label>
|
|
<input type="number" class="form-control" id="quotaSizeMB" name="quota_size"
|
|
placeholder="1024" min="0" step="1">
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="quotaUnitMB" class="form-label">Unit</label>
|
|
<select class="form-select" id="quotaUnitMB" name="quota_unit">
|
|
<option value="MB" selected>MB</option>
|
|
<option value="GB">GB</option>
|
|
<option value="TB">TB</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-text">
|
|
Set the maximum storage size for this bucket. Set to 0 to remove quota.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-warning">
|
|
<i class="fas fa-save me-1"></i>Update Quota
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bucket Details Modal -->
|
|
<div class="modal fade" id="bucketDetailsModal" tabindex="-1" aria-labelledby="bucketDetailsModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="bucketDetailsModalLabel">
|
|
<i class="fas fa-cube me-2"></i>Bucket Details
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="bucketDetailsContent">
|
|
<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 bucket details...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bucket Lifecycle Modal -->
|
|
<div class="modal fade" id="bucketLifecycleModal" tabindex="-1" aria-labelledby="bucketLifecycleModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl">
|
|
<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-outline-danger me-auto" id="lifecycleDeleteAllBtn">
|
|
<i class="fas fa-trash me-1"></i>Delete all rules
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="lifecycleSaveAllBtn">
|
|
<i class="fas fa-save me-1"></i>Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@PolicyDatalists()
|
|
|
|
<!-- Bucket Policy Modal -->
|
|
<div class="modal fade" id="bucketPolicyModal" tabindex="-1" aria-labelledby="bucketPolicyModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="bucketPolicyModalLabel">
|
|
<i class="fas fa-file-shield me-2"></i>Bucket Policy
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="bucketPolicyLoading" 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 bucket policy...</div>
|
|
</div>
|
|
<div id="bucketPolicyError" class="alert alert-danger" style="display: none;"></div>
|
|
<div id="bucketPolicyEditorWrapper" style="display: none;">
|
|
<p class="text-muted small">
|
|
Every statement must specify a Principal, and every Resource must refer to this bucket.
|
|
Leave the document empty and click Delete to remove the policy.
|
|
</p>
|
|
<ul class="nav nav-tabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" id="bucketPolicyEditorTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#bucketPolicyEditorTab" role="tab" aria-controls="bucketPolicyEditorTab" aria-selected="true">Editor</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="bucketPolicyJsonTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#bucketPolicyJsonTab" role="tab" aria-controls="bucketPolicyJsonTab" aria-selected="false">JSON</button>
|
|
</li>
|
|
</ul>
|
|
<div class="tab-content border border-top-0 rounded-bottom p-3 mb-3">
|
|
<div class="tab-pane fade show active" id="bucketPolicyEditorTab" role="tabpanel" aria-labelledby="bucketPolicyEditorTabBtn">
|
|
<div id="bucketPolicyEditorBody"></div>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="bucketPolicyAddStatementBtn">
|
|
<i class="fas fa-plus me-1"></i>Add statement
|
|
</button>
|
|
</div>
|
|
<div class="tab-pane fade" id="bucketPolicyJsonTab" role="tabpanel" aria-labelledby="bucketPolicyJsonTabBtn">
|
|
<textarea class="form-control" id="bucketPolicyDocument" rows="15"
|
|
style="font-family: monospace;" spellcheck="false"
|
|
placeholder='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket-name/*"}]}'></textarea>
|
|
<div class="form-text">Enter the bucket policy document as JSON</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<button type="button" class="btn btn-outline-info btn-sm" id="bucketPolicyInsertSampleBtn">
|
|
<i class="fas fa-file-alt me-1"></i>Use Sample Policy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-danger me-auto" id="bucketPolicyDeleteBtn">
|
|
<i class="fas fa-trash me-1"></i>Delete
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="bucketPolicySaveBtn">
|
|
<i class="fas fa-save me-1"></i>Save
|
|
</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">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="manageOwnerModalLabel">
|
|
<i class="fas fa-user-edit me-2"></i>Manage Bucket Owner
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form id="ownerForm">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Bucket Name</label>
|
|
<input type="text" class="form-control" id="ownerBucketName" readonly>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="bucketOwnerSelect" class="form-label">Owner</label>
|
|
<select class="form-select" id="bucketOwnerSelect" name="owner">
|
|
<option value="">No owner (admin-only access)</option>
|
|
<!-- Options will be populated dynamically -->
|
|
</select>
|
|
<div class="form-text">
|
|
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ownerLoadingSpinner" class="text-center py-2" style="display: none;">
|
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
|
<span class="visually-hidden">Loading users...</span>
|
|
</div>
|
|
<span class="ms-2">Loading users...</span>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-info">
|
|
<i class="fas fa-save me-1"></i>Update Owner
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- JavaScript for bucket management -->
|
|
<script>
|
|
// Global state (shared between DOMContentLoaded handlers and global functions)
|
|
let deleteModalInstance = null;
|
|
let quotaModalInstance = null;
|
|
let ownerModalInstance = null;
|
|
let detailsModalInstance = null;
|
|
let lifecycleModalInstance = null;
|
|
let lifecycleRequestSeq = 0;
|
|
let policyModalInstance = null;
|
|
let policyRequestSeq = 0;
|
|
let policyEditorBucket = null;
|
|
// True only once the bucket-policy GET for the currently open bucket has
|
|
// actually completed successfully. The Save button lives outside the
|
|
// (initially hidden) editor wrapper, so it stays clickable while a load
|
|
// is in flight or has failed; without this guard, Save could commit
|
|
// whatever the editor happened to hold - a stale previous bucket's
|
|
// statements, or a freshly-initialized empty state - onto
|
|
// policyEditorBucket before its own policy ever loaded.
|
|
let bucketPolicyLoaded = false;
|
|
// True while a Save (PUT) or Delete (DELETE) request for the bucket
|
|
// policy is in flight, so a double-click - or Save and Delete fired in
|
|
// quick succession - can't send two overlapping mutations for the same
|
|
// bucket. Cleared on failure so the user can retry; left set on success,
|
|
// since the modal hides and the page reloads shortly after anyway.
|
|
let bucketPolicyMutationInFlight = false;
|
|
|
|
function setBucketPolicyMutationInFlight(inFlight) {
|
|
bucketPolicyMutationInFlight = inFlight;
|
|
document.getElementById('bucketPolicySaveBtn').disabled = inFlight;
|
|
document.getElementById('bucketPolicyDeleteBtn').disabled = inFlight;
|
|
}
|
|
|
|
let cachedUsers = null;
|
|
|
|
// Working copy of the lifecycle rules currently shown in the modal.
|
|
// Edits (add/edit/delete rule) only mutate this local state; nothing is
|
|
// sent to the server until "Save" or "Delete all rules" is clicked.
|
|
let lifecycleEditor = { bucket: null, rules: [], editingIndex: null, editingRule: null, rawXml: '', hasTransition: false };
|
|
|
|
function escapeHtml(v) {
|
|
return String(v ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Shadows admin.js's formatBytes for this page, so it keeps that
|
|
// signature and unit list; the extra guard is for a size the API left
|
|
// out, which the shared one renders as "NaN undefined".
|
|
function formatBytes(bytes, decimals = 2) {
|
|
if (!bytes) return '0 Bytes';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDays(n) {
|
|
return n + (n === 1 ? ' day' : ' days');
|
|
}
|
|
|
|
// csrfHeaders reads the token the layout stamps into every page
|
|
// (<meta name="csrf-token">) so mutating lifecycle requests pass it the
|
|
// same way the newer admin pages (e.g. s3tables) do.
|
|
function csrfHeaders() {
|
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
const token = meta ? (meta.getAttribute('content') || '') : '';
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (token) {
|
|
headers['X-CSRF-Token'] = token;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
// parseLifecycleResponse reads the body as text before parsing, so a
|
|
// non-JSON response (a proxy's HTML error page, an empty body, plain
|
|
// text from some middleware) surfaces the HTTP status instead of
|
|
// throwing a raw JSON syntax error out of response.json().
|
|
function parseLifecycleResponse(response) {
|
|
return response.text().then(text => {
|
|
let data = {};
|
|
if (text) {
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (e) {
|
|
data = { error: response.statusText || ('HTTP ' + response.status) };
|
|
}
|
|
} else if (!response.ok) {
|
|
data = { error: response.statusText || ('HTTP ' + response.status) };
|
|
}
|
|
return { ok: response.ok, data: data };
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Add click handlers to pagination links
|
|
document.querySelectorAll('.pagination-link').forEach(link => {
|
|
link.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const page = this.getAttribute('data-page');
|
|
goToPage(page);
|
|
});
|
|
});
|
|
|
|
// Initialize modal instances once (reuse with show/hide)
|
|
deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
|
|
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'));
|
|
policyModalInstance = new bootstrap.Modal(document.getElementById('bucketPolicyModal'));
|
|
|
|
const quotaCheckbox = document.getElementById('enableQuota');
|
|
const quotaSettings = document.getElementById('quotaSettings');
|
|
const versioningCheckbox = document.getElementById('enableVersioning');
|
|
const objectLockCheckbox = document.getElementById('enableObjectLock');
|
|
const objectLockSettings = document.getElementById('objectLockSettings');
|
|
const setDefaultRetentionCheckbox = document.getElementById('setDefaultRetention');
|
|
const defaultRetentionSettings = document.getElementById('defaultRetentionSettings');
|
|
const createBucketForm = document.getElementById('createBucketForm');
|
|
|
|
// Toggle quota settings
|
|
quotaCheckbox.addEventListener('change', function() {
|
|
quotaSettings.style.display = this.checked ? 'block' : 'none';
|
|
});
|
|
|
|
// Toggle object lock settings and automatically enable versioning
|
|
objectLockCheckbox.addEventListener('change', function() {
|
|
objectLockSettings.style.display = this.checked ? 'block' : 'none';
|
|
if (this.checked) {
|
|
versioningCheckbox.checked = true;
|
|
versioningCheckbox.disabled = true;
|
|
} else {
|
|
versioningCheckbox.disabled = false;
|
|
// Reset default retention settings when object lock is disabled
|
|
setDefaultRetentionCheckbox.checked = false;
|
|
defaultRetentionSettings.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Toggle default retention settings
|
|
setDefaultRetentionCheckbox.addEventListener('change', function() {
|
|
defaultRetentionSettings.style.display = this.checked ? 'block' : 'none';
|
|
});
|
|
|
|
// Populate owner dropdown when create bucket modal opens
|
|
document.getElementById('createBucketModal').addEventListener('show.bs.modal', async function() {
|
|
const ownerSelect = document.getElementById('bucketOwner');
|
|
|
|
// Only fetch if not already populated
|
|
if (ownerSelect.options.length <= 1) {
|
|
try {
|
|
const response = await fetch(basePath('/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);
|
|
// Reset to default state on error - user can still create bucket without owner
|
|
ownerSelect.innerHTML = '<option value="">No owner (admin-only access)</option>';
|
|
ownerSelect.selectedIndex = 0;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle form submission
|
|
createBucketForm.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(this);
|
|
const data = {
|
|
name: formData.get('name'),
|
|
owner: formData.get('owner') || '',
|
|
region: formData.get('region') || '',
|
|
quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,
|
|
quota_unit: formData.get('quota_unit') || 'MB',
|
|
quota_enabled: quotaCheckbox.checked,
|
|
versioning_enabled: versioningCheckbox.checked,
|
|
object_lock_enabled: objectLockCheckbox.checked,
|
|
object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',
|
|
set_default_retention: setDefaultRetentionCheckbox.checked,
|
|
object_lock_duration: setDefaultRetentionCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0
|
|
};
|
|
|
|
// Validate object lock settings
|
|
if (data.object_lock_enabled && data.set_default_retention && data.object_lock_duration <= 0) {
|
|
alert('Please enter a valid retention duration for object lock.');
|
|
return;
|
|
}
|
|
|
|
fetch(basePath('/api/s3/buckets'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert('Error creating bucket: ' + data.error);
|
|
} else {
|
|
alert('Bucket created successfully!');
|
|
// Properly close the modal before reloading
|
|
const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));
|
|
if (createModal) {
|
|
createModal.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error creating bucket: ' + error.message);
|
|
});
|
|
});
|
|
|
|
// Handle delete bucket
|
|
const deleteForm = document.getElementById('deleteBucketModal');
|
|
document.querySelectorAll('.delete-bucket-btn').forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
const bucketName = this.dataset.bucketName;
|
|
document.getElementById('deleteBucketName').textContent = bucketName;
|
|
// Store bucket name on modal element instead of global window property
|
|
deleteForm.dataset.bucketName = bucketName;
|
|
deleteModalInstance.show();
|
|
});
|
|
});
|
|
|
|
// Handle quota management
|
|
const quotaForm = document.getElementById('quotaForm');
|
|
document.querySelectorAll('.quota-btn').forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
const bucketName = this.dataset.bucketName;
|
|
const currentQuota = parseInt(this.dataset.currentQuota);
|
|
const quotaEnabled = this.dataset.quotaEnabled === 'true';
|
|
|
|
document.getElementById('quotaBucketName').value = bucketName;
|
|
document.getElementById('quotaEnabled').checked = quotaEnabled;
|
|
document.getElementById('quotaSizeMB').value = currentQuota;
|
|
|
|
// Toggle quota size settings
|
|
document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';
|
|
|
|
// Store bucket name on form element instead of global window property
|
|
quotaForm.dataset.bucketName = bucketName;
|
|
quotaModalInstance.show();
|
|
});
|
|
});
|
|
|
|
// Add event listener to properly dispose of quota modal when hidden
|
|
document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {
|
|
if (quotaModalInstance) {
|
|
quotaModalInstance.dispose();
|
|
quotaModalInstance = null;
|
|
}
|
|
// Force remove any remaining backdrops
|
|
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
|
backdrop.remove();
|
|
});
|
|
// Ensure body classes are removed
|
|
document.body.classList.remove('modal-open');
|
|
document.body.style.removeProperty('padding-right');
|
|
});
|
|
|
|
// Handle quota form submission
|
|
document.getElementById('quotaForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const bucketName = this.dataset.bucketName;
|
|
if (!bucketName) return;
|
|
|
|
const formData = new FormData(this);
|
|
const enabled = document.getElementById('quotaEnabled').checked;
|
|
const data = {
|
|
quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,
|
|
quota_unit: formData.get('quota_unit') || 'MB',
|
|
quota_enabled: enabled
|
|
};
|
|
|
|
fetch(basePath(`/api/s3/buckets/${bucketName}/quota`), {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert('Error updating quota: ' + data.error);
|
|
} else {
|
|
alert('Quota updated successfully!');
|
|
// Properly close the modal before reloading
|
|
if (quotaModalInstance) {
|
|
quotaModalInstance.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error updating quota: ' + error.message);
|
|
});
|
|
});
|
|
|
|
// Handle quota enabled checkbox
|
|
document.getElementById('quotaEnabled').addEventListener('change', function() {
|
|
document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';
|
|
});
|
|
|
|
// Handle owner management
|
|
const ownerForm = document.getElementById('ownerForm');
|
|
document.querySelectorAll('.owner-btn').forEach(button => {
|
|
button.addEventListener('click', async function() {
|
|
const bucketName = this.dataset.bucketName;
|
|
const currentOwner = this.dataset.currentOwner || '';
|
|
|
|
document.getElementById('ownerBucketName').value = bucketName;
|
|
// Store bucket name on form element instead of global window property
|
|
ownerForm.dataset.bucketName = bucketName;
|
|
|
|
// Show loading spinner
|
|
document.getElementById('ownerLoadingSpinner').style.display = 'block';
|
|
document.getElementById('bucketOwnerSelect').disabled = true;
|
|
|
|
ownerModalInstance.show();
|
|
|
|
// Fetch users if not cached
|
|
try {
|
|
if (!cachedUsers) {
|
|
const response = await fetch(basePath('/api/users'));
|
|
const data = await response.json();
|
|
cachedUsers = data.users || [];
|
|
}
|
|
|
|
// Populate the select dropdown
|
|
const select = document.getElementById('bucketOwnerSelect');
|
|
select.innerHTML = '<option value="">No owner (admin-only access)</option>';
|
|
|
|
cachedUsers.forEach(user => {
|
|
const option = document.createElement('option');
|
|
option.value = user.username;
|
|
option.textContent = user.username;
|
|
if (user.username === currentOwner) {
|
|
option.selected = true;
|
|
}
|
|
select.appendChild(option);
|
|
});
|
|
|
|
select.disabled = false;
|
|
} catch (error) {
|
|
console.error('Error fetching users:', error);
|
|
alert('Error loading users: ' + error.message);
|
|
// Re-enable select and reset to default on error
|
|
const select = document.getElementById('bucketOwnerSelect');
|
|
select.innerHTML = '<option value="">No owner (admin-only access)</option>';
|
|
select.selectedIndex = 0;
|
|
select.disabled = false;
|
|
} finally {
|
|
document.getElementById('ownerLoadingSpinner').style.display = 'none';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Handle owner form submission
|
|
document.getElementById('ownerForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const bucketName = this.dataset.bucketName;
|
|
if (!bucketName) return;
|
|
|
|
const owner = document.getElementById('bucketOwnerSelect').value;
|
|
const data = { owner: owner };
|
|
|
|
fetch(basePath(`/api/s3/buckets/${bucketName}/owner`), {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert('Error updating owner: ' + data.error);
|
|
} else {
|
|
alert('Bucket owner updated successfully!');
|
|
// Properly close the modal before reloading
|
|
if (ownerModalInstance) {
|
|
ownerModalInstance.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error updating owner: ' + error.message);
|
|
});
|
|
});
|
|
|
|
// Handle view details button
|
|
document.querySelectorAll('.view-details-btn').forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
const bucketName = this.dataset.bucketName;
|
|
|
|
// Update modal title
|
|
document.getElementById('bucketDetailsModalLabel').innerHTML =
|
|
'<i class="fas fa-cube me-2"></i>Bucket Details - ' + bucketName;
|
|
|
|
// Show loading spinner
|
|
document.getElementById('bucketDetailsContent').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 bucket details...</div>' +
|
|
'<\\/div>';
|
|
|
|
detailsModalInstance.show();
|
|
|
|
// Fetch bucket details
|
|
fetch(basePath('/api/s3/buckets/' + bucketName))
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
document.getElementById('bucketDetailsContent').innerHTML =
|
|
'<div class="alert alert-danger">' +
|
|
'<i class="fas fa-exclamation-triangle me-2"></i>' +
|
|
'Error loading bucket details: ' + data.error +
|
|
'<\\/div>';
|
|
} else {
|
|
displayBucketDetails(data);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching bucket details:', error);
|
|
document.getElementById('bucketDetailsContent').innerHTML =
|
|
'<div class="alert alert-danger">' +
|
|
'<i class="fas fa-exclamation-triangle me-2"></i>' +
|
|
'Error loading bucket details: ' + error.message +
|
|
'<\\/div>';
|
|
});
|
|
});
|
|
});
|
|
|
|
// Lifecycle buttons: column badge and action button
|
|
document.querySelectorAll('.lifecycle-btn').forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
const bucketName = this.dataset.bucketName;
|
|
|
|
// Reset the working copy until the GET below repopulates it,
|
|
// so Save/Delete-all can't act on the previous bucket's rules.
|
|
lifecycleEditor = { bucket: null, rules: [], editingIndex: null, editingRule: null, rawXml: '', hasTransition: false };
|
|
|
|
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(parseLifecycleResponse)
|
|
.then(({ ok, data }) => {
|
|
if (requestSeq !== lifecycleRequestSeq) return;
|
|
if (!ok || 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 || 'unknown 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>';
|
|
});
|
|
});
|
|
});
|
|
|
|
// Save the whole working rule set back to the bucket.
|
|
document.getElementById('lifecycleSaveAllBtn').addEventListener('click', function() {
|
|
if (!lifecycleEditor.bucket) return;
|
|
// Fold in any rule the user is still editing so Save doesn't
|
|
// silently discard it; abort if that form isn't valid yet.
|
|
if (!commitOpenLifecycleRuleForm()) return;
|
|
// An empty list clears the configuration, same as Delete all rules,
|
|
// so it gets the same prompt.
|
|
if (lifecycleEditor.rules.length === 0 &&
|
|
!confirm('No rules left: saving removes the lifecycle configuration for ' + lifecycleEditor.bucket + '. This cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
fetch(basePath('/api/s3/buckets/' + lifecycleEditor.bucket + '/lifecycle'), {
|
|
method: 'PUT',
|
|
headers: csrfHeaders(),
|
|
body: JSON.stringify({ rules: lifecycleEditor.rules })
|
|
})
|
|
.then(parseLifecycleResponse)
|
|
.then(({ ok, data }) => {
|
|
if (!ok || data.error) {
|
|
alert('Error saving lifecycle rules: ' + (data.error || 'unknown error'));
|
|
return;
|
|
}
|
|
if (lifecycleModalInstance) {
|
|
lifecycleModalInstance.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error saving lifecycle rules: ' + error.message);
|
|
});
|
|
});
|
|
|
|
// Clear the lifecycle configuration entirely.
|
|
document.getElementById('lifecycleDeleteAllBtn').addEventListener('click', function() {
|
|
if (!lifecycleEditor.bucket) return;
|
|
if (!confirm('Delete all lifecycle rules for ' + lifecycleEditor.bucket + '? This cannot be undone.')) return;
|
|
|
|
fetch(basePath('/api/s3/buckets/' + lifecycleEditor.bucket + '/lifecycle'), {
|
|
method: 'DELETE',
|
|
headers: csrfHeaders()
|
|
})
|
|
.then(parseLifecycleResponse)
|
|
.then(({ ok, data }) => {
|
|
if (!ok || data.error) {
|
|
alert('Error deleting lifecycle rules: ' + (data.error || 'unknown error'));
|
|
return;
|
|
}
|
|
if (lifecycleModalInstance) {
|
|
lifecycleModalInstance.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error deleting lifecycle rules: ' + error.message);
|
|
});
|
|
});
|
|
|
|
// Policy buttons: column badge and action button. The visual editor
|
|
// (weed/admin/static/js/policy_editor.js) is set up once per page
|
|
// load; registerPolicyEditor is re-called on every open so the
|
|
// resource-ARN autocomplete and default Principal stay pinned to
|
|
// whichever bucket is currently open.
|
|
let bucketPolicyEditorSetUp = false;
|
|
document.getElementById('bucketPolicyInsertSampleBtn').addEventListener('click', function() {
|
|
if (!policyEditorBucket) return;
|
|
insertSamplePolicy('bucket', {
|
|
Version: '2012-10-17',
|
|
Statement: [{
|
|
Effect: 'Allow',
|
|
Principal: '*',
|
|
Action: ['s3:GetObject'],
|
|
Resource: ['arn:aws:s3:::' + policyEditorBucket + '/*']
|
|
}]
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.policy-btn').forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
const bucketName = this.dataset.bucketName;
|
|
policyEditorBucket = bucketName;
|
|
bucketPolicyLoaded = false;
|
|
// Deliberately not resetting bucketPolicyMutationInFlight
|
|
// here: if a Save/Delete for a previously open bucket is
|
|
// still in flight, Save/Delete for this bucket must stay
|
|
// blocked until that request settles (its own completion
|
|
// handler releases the flag once done, whether or not it's
|
|
// still the active bucket by then) - otherwise this bucket's
|
|
// mutation could race the abandoned one.
|
|
registerPolicyEditor('bucket', { requirePrincipal: true, bucket: bucketName });
|
|
if (!bucketPolicyEditorSetUp) {
|
|
setupPolicyEditor('bucket');
|
|
bucketPolicyEditorSetUp = true;
|
|
}
|
|
|
|
document.getElementById('bucketPolicyModalLabel').innerHTML =
|
|
'<i class="fas fa-file-shield me-2"></i>Bucket Policy - ' + escapeHtml(bucketName);
|
|
document.getElementById('bucketPolicyLoading').style.display = '';
|
|
document.getElementById('bucketPolicyError').style.display = 'none';
|
|
document.getElementById('bucketPolicyEditorWrapper').style.display = 'none';
|
|
|
|
policyModalInstance.show();
|
|
|
|
// Drop responses that arrive after another bucket was opened
|
|
const requestSeq = ++policyRequestSeq;
|
|
fetch(basePath('/api/s3/buckets/' + encodeURIComponent(bucketName) + '/policy'))
|
|
.then(parseLifecycleResponse)
|
|
.then(({ ok, data }) => {
|
|
if (requestSeq !== policyRequestSeq) return;
|
|
document.getElementById('bucketPolicyLoading').style.display = 'none';
|
|
if (!ok || data.error) {
|
|
document.getElementById('bucketPolicyError').textContent =
|
|
'Error loading bucket policy: ' + (data.error || 'unknown error');
|
|
document.getElementById('bucketPolicyError').style.display = '';
|
|
return;
|
|
}
|
|
document.getElementById('bucketPolicyEditorWrapper').style.display = '';
|
|
// policy_text carries stored bytes the server-side
|
|
// decoder rejects; the JSON tab shows them so they
|
|
// can be fixed or deleted.
|
|
document.getElementById('bucketPolicyDocument').value =
|
|
data.policy ? JSON.stringify(data.policy, null, 2) : (data.policy_text || '');
|
|
bucketPolicyLoaded = true;
|
|
loadPolicyTextareaIntoEditor('bucket');
|
|
})
|
|
.catch(error => {
|
|
if (requestSeq !== policyRequestSeq) return;
|
|
console.error('Error fetching bucket policy:', error);
|
|
document.getElementById('bucketPolicyLoading').style.display = 'none';
|
|
document.getElementById('bucketPolicyError').textContent =
|
|
'Error loading bucket policy: ' + error.message;
|
|
document.getElementById('bucketPolicyError').style.display = '';
|
|
});
|
|
});
|
|
});
|
|
|
|
// Save the policy document currently shown (whichever tab is active).
|
|
document.getElementById('bucketPolicySaveBtn').addEventListener('click', function() {
|
|
if (!policyEditorBucket) return;
|
|
if (bucketPolicyMutationInFlight) return;
|
|
if (!bucketPolicyLoaded) {
|
|
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
|
|
return;
|
|
}
|
|
|
|
// Commit first: if the Editor tab is active, the textarea still
|
|
// holds whatever was there at load time until this runs, so a
|
|
// policy entered purely through the structured form (no tab
|
|
// switch) would otherwise read back as empty here.
|
|
if (!commitPolicyActiveTab('bucket')) return;
|
|
|
|
const raw = document.getElementById('bucketPolicyDocument').value.trim();
|
|
if (!raw || !policyTextHasStatements(raw)) {
|
|
alert('Add at least one statement, or use Delete to remove the bucket policy.');
|
|
return;
|
|
}
|
|
|
|
let policy;
|
|
try {
|
|
policy = JSON.parse(document.getElementById('bucketPolicyDocument').value);
|
|
} catch (e) {
|
|
alert('Invalid JSON: ' + e.message);
|
|
return;
|
|
}
|
|
const validationError = validatePolicyEditorDoc('bucket', policy);
|
|
if (validationError) {
|
|
alert(validationError);
|
|
return;
|
|
}
|
|
// The admin API decodes only Version and Statement, so warn
|
|
// before other top-level fields are silently dropped - the IAM
|
|
// policies page already does. (The S3 Tables dialogs don't:
|
|
// their backend stores the document verbatim.)
|
|
if (!confirmPolicyFieldDiscard('bucket')) return;
|
|
|
|
// Captured now: if the dialog moves on to a different bucket
|
|
// before this PUT resolves, the completion below must not hide
|
|
// or reload over whatever that other bucket is now showing.
|
|
const targetBucket = policyEditorBucket;
|
|
setBucketPolicyMutationInFlight(true);
|
|
fetch(basePath('/api/s3/buckets/' + encodeURIComponent(targetBucket) + '/policy'), {
|
|
method: 'PUT',
|
|
headers: csrfHeaders(),
|
|
body: JSON.stringify({ policy: policy })
|
|
})
|
|
.then(parseLifecycleResponse)
|
|
.then(({ ok, data }) => {
|
|
setBucketPolicyMutationInFlight(false);
|
|
const stillCurrent = targetBucket === policyEditorBucket;
|
|
if (!ok || data.error) {
|
|
if (stillCurrent) {
|
|
alert('Error saving bucket policy: ' + (data.error || 'unknown error'));
|
|
} else {
|
|
console.error('Error saving policy for ' + targetBucket + ' (no longer the open bucket): ' + (data.error || 'unknown error'));
|
|
}
|
|
return;
|
|
}
|
|
if (!stillCurrent) return;
|
|
if (policyModalInstance) {
|
|
policyModalInstance.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
})
|
|
.catch(error => {
|
|
setBucketPolicyMutationInFlight(false);
|
|
console.error('Error:', error);
|
|
if (targetBucket === policyEditorBucket) {
|
|
alert('Error saving bucket policy: ' + error.message);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Clear the bucket policy entirely.
|
|
document.getElementById('bucketPolicyDeleteBtn').addEventListener('click', function() {
|
|
if (!policyEditorBucket) return;
|
|
if (bucketPolicyMutationInFlight) return;
|
|
if (!bucketPolicyLoaded) {
|
|
alert('The current policy has not finished loading. Close and reopen this dialog before deleting.');
|
|
return;
|
|
}
|
|
if (!confirm('Delete the bucket policy for ' + policyEditorBucket + '? This cannot be undone.')) return;
|
|
|
|
// Captured now: if the dialog moves on to a different bucket
|
|
// before this DELETE resolves, the completion below must not
|
|
// hide or reload over whatever that other bucket is now showing.
|
|
const targetBucket = policyEditorBucket;
|
|
setBucketPolicyMutationInFlight(true);
|
|
fetch(basePath('/api/s3/buckets/' + encodeURIComponent(targetBucket) + '/policy'), {
|
|
method: 'DELETE',
|
|
headers: csrfHeaders()
|
|
})
|
|
.then(parseLifecycleResponse)
|
|
.then(({ ok, data }) => {
|
|
setBucketPolicyMutationInFlight(false);
|
|
const stillCurrent = targetBucket === policyEditorBucket;
|
|
if (!ok || data.error) {
|
|
if (stillCurrent) {
|
|
alert('Error deleting bucket policy: ' + (data.error || 'unknown error'));
|
|
} else {
|
|
console.error('Error deleting policy for ' + targetBucket + ' (no longer the open bucket): ' + (data.error || 'unknown error'));
|
|
}
|
|
return;
|
|
}
|
|
if (!stillCurrent) return;
|
|
if (policyModalInstance) {
|
|
policyModalInstance.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
})
|
|
.catch(error => {
|
|
setBucketPolicyMutationInFlight(false);
|
|
console.error('Error:', error);
|
|
if (targetBucket === policyEditorBucket) {
|
|
alert('Error deleting bucket policy: ' + error.message);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
function deleteBucket() {
|
|
const bucketName = document.getElementById('deleteBucketModal').dataset.bucketName;
|
|
if (!bucketName) return;
|
|
|
|
fetch(basePath(`/api/s3/buckets/${bucketName}`), {
|
|
method: 'DELETE'
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert('Error deleting bucket: ' + data.error);
|
|
} else {
|
|
alert('Bucket deleted successfully!');
|
|
// Properly close the modal before reloading
|
|
if (deleteModalInstance) {
|
|
deleteModalInstance.hide();
|
|
}
|
|
setTimeout(() => location.reload(), 500);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error deleting bucket: ' + error.message);
|
|
});
|
|
}
|
|
|
|
function displayBucketDetails(data) {
|
|
const bucket = data.bucket;
|
|
|
|
function formatDate(dateString) {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
let ownerHtml = '<span class="text-muted">No owner (admin-only)</span>';
|
|
if (bucket.owner) {
|
|
ownerHtml = '<span class="badge bg-info"><i class="fas fa-user me-1"></i>' + escapeHtml(bucket.owner) + '</span>';
|
|
}
|
|
|
|
let usageHtml = '';
|
|
if (bucket.physical_size > 0 && bucket.logical_size > 0 && bucket.physical_size > bucket.logical_size) {
|
|
const overhead = (bucket.physical_size / bucket.logical_size).toFixed(1);
|
|
usageHtml = '<br><small class="text-muted">' + overhead + 'x overhead<\/small>';
|
|
}
|
|
|
|
let quotaHtml = '<span class="badge bg-secondary">Disabled</span>';
|
|
if (bucket.quota_enabled) {
|
|
quotaHtml = '<span class="badge bg-success">' + formatBytes(bucket.quota) + '</span>';
|
|
}
|
|
|
|
let versioningHtml = '<span class="text-muted">Not configured</span>';
|
|
if (bucket.versioning_status === 'Enabled') {
|
|
versioningHtml = '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Enabled</span>';
|
|
} else if (bucket.versioning_status === 'Suspended') {
|
|
versioningHtml = '<span class="badge bg-warning"><i class="fas fa-pause me-1"></i>Suspended</span>';
|
|
}
|
|
|
|
let statusHtml = '<span class="badge bg-success">Writable</span>';
|
|
if (bucket.read_only) {
|
|
statusHtml = '<span class="badge bg-danger"><i class="fas fa-lock me-1"></i>Read-only</span>';
|
|
}
|
|
|
|
let objectLockHtml = '<span class="text-muted">Not configured</span>';
|
|
if (bucket.object_lock_enabled) {
|
|
let details = '';
|
|
if (bucket.object_lock_mode && bucket.object_lock_duration > 0) {
|
|
details = '<br><small class="text-muted">' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days<\/small>';
|
|
}
|
|
objectLockHtml = '<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' + details;
|
|
}
|
|
|
|
let policyHtml = '<span class="text-muted">Not configured</span>';
|
|
if (bucket.policy_statement_count > 0) {
|
|
const label = bucket.policy_statement_count === 1 ? '1 statement' : bucket.policy_statement_count + ' statements';
|
|
policyHtml = '<span class="badge bg-primary">' + label + '<\/span>';
|
|
}
|
|
|
|
const rows = [
|
|
'<div class="row">',
|
|
'<div class="col-md-6">',
|
|
'<h6><i class="fas fa-info-circle me-2"></i>Bucket Information</h6>',
|
|
'<table class="table table-sm">',
|
|
'<tr><td><strong>Name:</strong></td><td>' + escapeHtml(bucket.name) + '<\/td><\/tr>',
|
|
'<tr><td><strong>Owner:</strong></td><td>' + ownerHtml + '<\/td><\/tr>',
|
|
'<tr><td><strong>Created:</strong></td><td>' + formatDate(bucket.created_at) + '<\/td><\/tr>',
|
|
'<tr><td><strong>Last Modified:</strong></td><td>' + formatDate(bucket.last_modified) + '<\/td><\/tr>',
|
|
'<tr><td><strong>Logical Size:</strong></td><td>' + formatBytes(bucket.logical_size) + '<\/td><\/tr>',
|
|
'<tr><td><strong>Physical Size:</strong></td><td>' + formatBytes(bucket.physical_size) + usageHtml + '<\/td><\/tr>',
|
|
'<\/table>',
|
|
'<\/div>',
|
|
'<div class="col-md-6">',
|
|
'<h6><i class="fas fa-cogs me-2"></i>Configuration</h6>',
|
|
'<table class="table table-sm">',
|
|
'<tr><td><strong>Status:</strong></td><td>' + statusHtml + '<\/td><\/tr>',
|
|
'<tr><td><strong>Quota:</strong></td><td>' + quotaHtml + '<\/td><\/tr>',
|
|
'<tr><td><strong>Versioning:</strong></td><td>' + versioningHtml + '<\/td><\/tr>',
|
|
'<tr><td><strong>Object Lock:</strong></td><td>' + objectLockHtml + '<\/td><\/tr>',
|
|
'<tr><td><strong>Policy:</strong></td><td>' + policyHtml + '<\/td><\/tr>',
|
|
'<\/table>',
|
|
'<\/div>',
|
|
'<\/div>'
|
|
];
|
|
|
|
document.getElementById('bucketDetailsContent').innerHTML = rows.join('');
|
|
}
|
|
|
|
// displayBucketLifecycle loads the GET response into the local working copy
|
|
// and renders the editor. Nothing is sent back to the server until Save or
|
|
// Delete all rules is clicked.
|
|
function displayBucketLifecycle(data) {
|
|
lifecycleEditor.bucket = data.bucket || lifecycleEditor.bucket;
|
|
lifecycleEditor.rules = (data.rules || []).map(cloneLifecycleRule);
|
|
lifecycleEditor.editingIndex = null;
|
|
lifecycleEditor.editingRule = null;
|
|
lifecycleEditor.rawXml = data.xml || '';
|
|
// SeaweedFS doesn't support Transition rules (PutBucketLifecycleConfiguration
|
|
// rejects them for Enabled rules), and the canonical rule shape the admin
|
|
// edits has no field for one, so a Transition set by another client would
|
|
// silently disappear on the next Save. Warn instead.
|
|
// Matches <Transition> and <NoncurrentVersionTransition> however they are
|
|
// spelled: with attributes, self-closed, or namespace-prefixed.
|
|
lifecycleEditor.hasTransition = /<(\w+:)?(NoncurrentVersion)?Transition[\s>\/]/.test(data.xml || '');
|
|
renderLifecycleEditor();
|
|
}
|
|
|
|
// normalizeLifecycleStatus maps a stored status onto the two the editor can
|
|
// show. The S3 API stores <Status> unvalidated, and the engine runs a rule
|
|
// only on an exact "Enabled", so everything else is Disabled - collapsing it
|
|
// here keeps an odd spelling from leaving both radios unchecked, which would
|
|
// throw out of readLifecycleRuleFromForm and make the modal look dead.
|
|
function normalizeLifecycleStatus(status) {
|
|
return status === 'Enabled' ? 'Enabled' : 'Disabled';
|
|
}
|
|
|
|
function cloneLifecycleRule(r) {
|
|
return {
|
|
id: r.id || '',
|
|
status: normalizeLifecycleStatus(r.status),
|
|
prefix: r.prefix || '',
|
|
tags: Object.assign({}, r.tags || {}),
|
|
size_greater_than: r.size_greater_than || null,
|
|
size_less_than: r.size_less_than || null,
|
|
expiration_days: r.expiration_days || null,
|
|
expiration_date: r.expiration_date || '',
|
|
expired_object_delete_marker: !!r.expired_object_delete_marker,
|
|
noncurrent_version_expiration_days: r.noncurrent_version_expiration_days || null,
|
|
newer_noncurrent_versions: r.newer_noncurrent_versions || null,
|
|
abort_multipart_days: r.abort_multipart_days || null,
|
|
};
|
|
}
|
|
|
|
function blankLifecycleRule() {
|
|
return {
|
|
id: '', status: 'Enabled', prefix: '', tags: {},
|
|
size_greater_than: null, size_less_than: null,
|
|
expiration_days: null, expiration_date: '',
|
|
expired_object_delete_marker: false,
|
|
noncurrent_version_expiration_days: null, newer_noncurrent_versions: null,
|
|
abort_multipart_days: null,
|
|
};
|
|
}
|
|
|
|
function describeLifecycleScope(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 describeLifecycleActions(rule) {
|
|
const actions = [];
|
|
if (rule.expiration_days) {
|
|
actions.push('Expire after ' + formatDays(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 ' + formatDays(rule.noncurrent_version_expiration_days);
|
|
if (rule.newer_noncurrent_versions) {
|
|
action += ', keep ' + rule.newer_noncurrent_versions + ' newest';
|
|
}
|
|
actions.push(action);
|
|
} else if (rule.newer_noncurrent_versions) {
|
|
// Standalone (no expiration_days): the engine still recognizes this
|
|
// as an action that expires noncurrent versions beyond the N newest.
|
|
actions.push('Keep ' + rule.newer_noncurrent_versions + ' newest noncurrent versions');
|
|
}
|
|
if (rule.abort_multipart_days) {
|
|
actions.push('Abort incomplete multipart uploads after ' + formatDays(rule.abort_multipart_days));
|
|
}
|
|
if (actions.length === 0) {
|
|
return '<span class="text-muted">None<\/span>';
|
|
}
|
|
return actions.join('<br>');
|
|
}
|
|
|
|
function lifecycleRuleRowHtml(rule, idx) {
|
|
const statusHtml = rule.status === 'Enabled'
|
|
? '<span class="badge bg-success">Enabled<\/span>'
|
|
: '<span class="badge bg-secondary">' + escapeHtml(rule.status) + '<\/span>';
|
|
return '<tr>' +
|
|
'<td>' + (rule.id ? escapeHtml(rule.id) : '<span class="text-muted">unnamed<\/span>') + '<\/td>' +
|
|
'<td>' + statusHtml + '<\/td>' +
|
|
'<td>' + describeLifecycleScope(rule) + '<\/td>' +
|
|
'<td>' + describeLifecycleActions(rule) + '<\/td>' +
|
|
'<td class="text-end text-nowrap">' +
|
|
'<button type="button" class="btn btn-sm btn-outline-secondary lifecycle-edit-rule-btn" data-index="' + idx + '" title="Edit rule"><i class="fas fa-pen"><\/i><\/button> ' +
|
|
'<button type="button" class="btn btn-sm btn-outline-danger lifecycle-delete-rule-btn" data-index="' + idx + '" title="Delete rule"><i class="fas fa-trash"><\/i><\/button>' +
|
|
'<\/td>' +
|
|
'<\/tr>';
|
|
}
|
|
|
|
function lifecycleRuleFormHtml() {
|
|
const rule = lifecycleEditor.editingRule;
|
|
const tagRowHtml = (k, v) =>
|
|
'<div class="input-group input-group-sm mb-1 lifecycle-tag-row">' +
|
|
'<input type="text" class="form-control lifecycle-tag-key" placeholder="Key" value="' + escapeHtml(k) + '">' +
|
|
'<input type="text" class="form-control lifecycle-tag-value" placeholder="Value" value="' + escapeHtml(v) + '">' +
|
|
'<button type="button" class="btn btn-outline-danger lifecycle-remove-tag-btn"><i class="fas fa-times"><\/i><\/button>' +
|
|
'<\/div>';
|
|
const tagRows = Object.entries(rule.tags || {}).map(([k, v]) => tagRowHtml(k, v)).join('');
|
|
|
|
return '' +
|
|
'<div class="card mb-3" id="lifecycleRuleForm">' +
|
|
'<div class="card-body">' +
|
|
'<h6 class="card-title">' + (lifecycleEditor.editingIndex === 'new' ? 'Add rule' : 'Edit rule') + '<\/h6>' +
|
|
|
|
'<div class="row g-2 mb-2">' +
|
|
'<div class="col-md-6">' +
|
|
'<label class="form-label small">ID (optional)<\/label>' +
|
|
'<input type="text" class="form-control form-control-sm" id="lifecycleRuleId" value="' + escapeHtml(rule.id) + '">' +
|
|
'<\/div>' +
|
|
'<div class="col-md-6">' +
|
|
'<label class="form-label small d-block">Status<\/label>' +
|
|
'<div class="form-check form-check-inline">' +
|
|
'<input class="form-check-input" type="radio" name="lifecycleRuleStatus" id="lifecycleStatusEnabled" value="Enabled"' + (rule.status === 'Enabled' ? ' checked' : '') + '>' +
|
|
'<label class="form-check-label" for="lifecycleStatusEnabled">Enabled<\/label>' +
|
|
'<\/div>' +
|
|
'<div class="form-check form-check-inline">' +
|
|
'<input class="form-check-input" type="radio" name="lifecycleRuleStatus" id="lifecycleStatusDisabled" value="Disabled"' + (rule.status === 'Disabled' ? ' checked' : '') + '>' +
|
|
'<label class="form-check-label" for="lifecycleStatusDisabled">Disabled<\/label>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
|
|
'<div class="mb-2">' +
|
|
'<label class="form-label small">Prefix<\/label>' +
|
|
'<input type="text" class="form-control form-control-sm" id="lifecycleRulePrefix" placeholder="logs/" value="' + escapeHtml(rule.prefix) + '">' +
|
|
'<\/div>' +
|
|
|
|
'<div class="mb-2">' +
|
|
'<label class="form-label small d-block">Tags<\/label>' +
|
|
'<div id="lifecycleTagRows">' + tagRows + '<\/div>' +
|
|
'<button type="button" class="btn btn-sm btn-outline-secondary" id="lifecycleAddTagBtn"><i class="fas fa-plus me-1"><\/i>Add tag<\/button>' +
|
|
'<\/div>' +
|
|
|
|
'<div class="row g-2 mb-3">' +
|
|
'<div class="col-md-6">' +
|
|
'<label class="form-label small">Size greater than (bytes)<\/label>' +
|
|
'<input type="number" class="form-control form-control-sm" id="lifecycleRuleSizeGT" min="0" step="1" value="' + (rule.size_greater_than || '') + '">' +
|
|
'<\/div>' +
|
|
'<div class="col-md-6">' +
|
|
'<label class="form-label small">Size less than (bytes)<\/label>' +
|
|
'<input type="number" class="form-control form-control-sm" id="lifecycleRuleSizeLT" min="0" step="1" value="' + (rule.size_less_than || '') + '">' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
|
|
'<hr>' +
|
|
'<h6 class="small text-muted text-uppercase">Actions<\/h6>' +
|
|
|
|
'<div class="row g-2 align-items-center mb-2">' +
|
|
'<div class="col-auto">' +
|
|
'<div class="form-check">' +
|
|
'<input class="form-check-input" type="checkbox" id="lifecycleExpireDaysEnabled"' + (rule.expiration_days ? ' checked' : '') + '>' +
|
|
'<label class="form-check-label" for="lifecycleExpireDaysEnabled">Expire after<\/label>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
'<div class="col-auto">' +
|
|
'<input type="number" class="form-control form-control-sm" id="lifecycleExpireDays" min="1" step="1" style="width: 6rem;" value="' + (rule.expiration_days || '') + '"' + (rule.expiration_days ? '' : ' disabled') + '>' +
|
|
'<\/div><div class="col-auto">days<\/div>' +
|
|
'<\/div>' +
|
|
|
|
'<div class="row g-2 align-items-center mb-2">' +
|
|
'<div class="col-auto">' +
|
|
'<div class="form-check">' +
|
|
'<input class="form-check-input" type="checkbox" id="lifecycleExpireDateEnabled"' + (rule.expiration_date ? ' checked' : '') + '>' +
|
|
'<label class="form-check-label" for="lifecycleExpireDateEnabled">Expire on date<\/label>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
'<div class="col-auto">' +
|
|
'<input type="date" class="form-control form-control-sm" id="lifecycleExpireDate" value="' + escapeHtml(rule.expiration_date) + '"' + (rule.expiration_date ? '' : ' disabled') + '>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
|
|
'<div class="form-check mb-2">' +
|
|
'<input class="form-check-input" type="checkbox" id="lifecycleDeleteMarker"' + (rule.expired_object_delete_marker ? ' checked' : '') + '>' +
|
|
'<label class="form-check-label" for="lifecycleDeleteMarker">Remove expired object delete markers<\/label>' +
|
|
'<\/div>' +
|
|
|
|
(() => {
|
|
const noncurrentActive = !!(rule.noncurrent_version_expiration_days || rule.newer_noncurrent_versions);
|
|
return '<div class="row g-2 align-items-center mb-2">' +
|
|
'<div class="col-auto">' +
|
|
'<div class="form-check">' +
|
|
'<input class="form-check-input" type="checkbox" id="lifecycleNoncurrentEnabled"' + (noncurrentActive ? ' checked' : '') + '>' +
|
|
'<label class="form-check-label" for="lifecycleNoncurrentEnabled">Limit noncurrent versions<\/label>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
'<div class="col-auto">after<\/div>' +
|
|
'<div class="col-auto">' +
|
|
'<input type="number" class="form-control form-control-sm" id="lifecycleNoncurrentDays" min="1" step="1" style="width: 6rem;" value="' + (rule.noncurrent_version_expiration_days || '') + '"' + (noncurrentActive ? '' : ' disabled') + '>' +
|
|
'<\/div><div class="col-auto">days (optional), keep<\/div>' +
|
|
'<div class="col-auto">' +
|
|
'<input type="number" class="form-control form-control-sm" id="lifecycleNoncurrentKeep" min="0" step="1" style="width: 6rem;" value="' + (rule.newer_noncurrent_versions || '') + '"' + (noncurrentActive ? '' : ' disabled') + '>' +
|
|
'<\/div><div class="col-auto">newest (optional)<\/div>' +
|
|
'<\/div>';
|
|
})() +
|
|
|
|
'<div class="row g-2 align-items-center mb-3">' +
|
|
'<div class="col-auto">' +
|
|
'<div class="form-check">' +
|
|
'<input class="form-check-input" type="checkbox" id="lifecycleAbortMPUEnabled"' + (rule.abort_multipart_days ? ' checked' : '') + '>' +
|
|
'<label class="form-check-label" for="lifecycleAbortMPUEnabled">Abort incomplete multipart uploads after<\/label>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
'<div class="col-auto">' +
|
|
'<input type="number" class="form-control form-control-sm" id="lifecycleAbortMPUDays" min="1" step="1" style="width: 6rem;" value="' + (rule.abort_multipart_days || '') + '"' + (rule.abort_multipart_days ? '' : ' disabled') + '>' +
|
|
'<\/div><div class="col-auto">days<\/div>' +
|
|
'<\/div>' +
|
|
|
|
'<div class="text-end">' +
|
|
'<button type="button" class="btn btn-sm btn-secondary" id="lifecycleCancelRuleBtn">Cancel<\/button> ' +
|
|
'<button type="button" class="btn btn-sm btn-primary" id="lifecycleSaveRuleBtn">Save rule<\/button>' +
|
|
'<\/div>' +
|
|
'<\/div>' +
|
|
'<\/div>';
|
|
}
|
|
|
|
function renderLifecycleEditor() {
|
|
const container = document.getElementById('bucketLifecycleContent');
|
|
const rowsHtml = lifecycleEditor.rules.map(lifecycleRuleRowHtml).join('') ||
|
|
'<tr><td colspan="5" class="text-center text-muted py-3">No lifecycle rules<\/td><\/tr>';
|
|
|
|
let html = '';
|
|
if (lifecycleEditor.hasTransition) {
|
|
html += '<div class="alert alert-warning small">' +
|
|
'<i class="fas fa-triangle-exclamation me-1"><\/i>' +
|
|
'This bucket has a Transition rule set outside the admin (SeaweedFS does not support storage class transitions). Saving from here will drop it.' +
|
|
'<\/div>';
|
|
}
|
|
|
|
html += '<div class="table-responsive mb-3">' +
|
|
'<table class="table table-sm align-middle">' +
|
|
'<thead><tr><th>Rule<\/th><th>Status<\/th><th>Scope<\/th><th>Actions<\/th><th><\/th><\/tr><\/thead>' +
|
|
'<tbody>' + rowsHtml + '<\/tbody>' +
|
|
'<\/table>' +
|
|
'<\/div>' +
|
|
'<button type="button" class="btn btn-sm btn-outline-primary mb-3" id="lifecycleAddRuleBtn">' +
|
|
'<i class="fas fa-plus me-1"><\/i>Add rule' +
|
|
'<\/button>';
|
|
|
|
if (lifecycleEditor.editingIndex !== null) {
|
|
html += lifecycleRuleFormHtml();
|
|
}
|
|
|
|
if (lifecycleEditor.rawXml) {
|
|
html += '<details class="mt-2">' +
|
|
'<summary class="text-muted small">Raw XML (as currently stored)<\/summary>' +
|
|
'<pre class="bg-light border rounded p-2 mt-2 small mb-0">' + escapeHtml(lifecycleEditor.rawXml) + '<\/pre>' +
|
|
'<\/details>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
bindLifecycleEditorEvents();
|
|
}
|
|
|
|
function bindLifecycleToggle(checkboxId, fieldId, mutuallyExclusiveId) {
|
|
const checkbox = document.getElementById(checkboxId);
|
|
const field = document.getElementById(fieldId);
|
|
if (!checkbox || !field) return;
|
|
checkbox.addEventListener('change', function() {
|
|
field.disabled = !this.checked;
|
|
if (this.checked && mutuallyExclusiveId) {
|
|
const other = document.getElementById(mutuallyExclusiveId);
|
|
if (other && other.checked) {
|
|
other.checked = false;
|
|
other.dispatchEvent(new Event('change'));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function bindLifecycleEditorEvents() {
|
|
const addBtn = document.getElementById('lifecycleAddRuleBtn');
|
|
if (addBtn) {
|
|
addBtn.addEventListener('click', function() {
|
|
// Fold in any rule already being edited before switching forms,
|
|
// so an invalid in-progress draft isn't silently discarded.
|
|
if (!commitOpenLifecycleRuleForm()) return;
|
|
lifecycleEditor.editingIndex = 'new';
|
|
lifecycleEditor.editingRule = blankLifecycleRule();
|
|
renderLifecycleEditor();
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('.lifecycle-edit-rule-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
const idx = parseInt(this.dataset.index, 10);
|
|
// Same as above: committing never shifts indices (it either
|
|
// pushes a new rule at the end or replaces one in place), so
|
|
// idx captured above is still valid after this call.
|
|
if (!commitOpenLifecycleRuleForm()) return;
|
|
lifecycleEditor.editingIndex = idx;
|
|
lifecycleEditor.editingRule = cloneLifecycleRule(lifecycleEditor.rules[idx]);
|
|
renderLifecycleEditor();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.lifecycle-delete-rule-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
const idx = parseInt(this.dataset.index, 10);
|
|
// Fold in an open form first, like every other transition does:
|
|
// re-rendering after the splice would otherwise redraw it from the
|
|
// snapshot taken when editing began and lose what was typed.
|
|
if (lifecycleEditor.editingIndex !== null && lifecycleEditor.editingIndex !== idx) {
|
|
if (!commitOpenLifecycleRuleForm()) return;
|
|
}
|
|
lifecycleEditor.rules.splice(idx, 1);
|
|
// Deleting a rule shifts every later index down by one, so an
|
|
// open edit form must follow its rule rather than silently
|
|
// start targeting whatever now sits at its old index.
|
|
if (lifecycleEditor.editingIndex === idx) {
|
|
lifecycleEditor.editingIndex = null;
|
|
lifecycleEditor.editingRule = null;
|
|
} else if (typeof lifecycleEditor.editingIndex === 'number' && lifecycleEditor.editingIndex > idx) {
|
|
lifecycleEditor.editingIndex -= 1;
|
|
}
|
|
renderLifecycleEditor();
|
|
});
|
|
});
|
|
|
|
const cancelBtn = document.getElementById('lifecycleCancelRuleBtn');
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener('click', function() {
|
|
lifecycleEditor.editingIndex = null;
|
|
lifecycleEditor.editingRule = null;
|
|
renderLifecycleEditor();
|
|
});
|
|
}
|
|
|
|
const addTagBtn = document.getElementById('lifecycleAddTagBtn');
|
|
if (addTagBtn) {
|
|
addTagBtn.addEventListener('click', function() {
|
|
const rows = document.getElementById('lifecycleTagRows');
|
|
const row = document.createElement('div');
|
|
row.className = 'input-group input-group-sm mb-1 lifecycle-tag-row';
|
|
row.innerHTML =
|
|
'<input type="text" class="form-control lifecycle-tag-key" placeholder="Key">' +
|
|
'<input type="text" class="form-control lifecycle-tag-value" placeholder="Value">' +
|
|
'<button type="button" class="btn btn-outline-danger lifecycle-remove-tag-btn"><i class="fas fa-times"><\/i><\/button>';
|
|
rows.appendChild(row);
|
|
row.querySelector('.lifecycle-remove-tag-btn').addEventListener('click', function() {
|
|
row.remove();
|
|
});
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('.lifecycle-remove-tag-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
this.closest('.lifecycle-tag-row').remove();
|
|
});
|
|
});
|
|
|
|
bindLifecycleToggle('lifecycleExpireDaysEnabled', 'lifecycleExpireDays', 'lifecycleExpireDateEnabled');
|
|
bindLifecycleToggle('lifecycleExpireDateEnabled', 'lifecycleExpireDate', 'lifecycleExpireDaysEnabled');
|
|
bindLifecycleToggle('lifecycleNoncurrentEnabled', 'lifecycleNoncurrentDays');
|
|
bindLifecycleToggle('lifecycleNoncurrentEnabled', 'lifecycleNoncurrentKeep');
|
|
bindLifecycleToggle('lifecycleAbortMPUEnabled', 'lifecycleAbortMPUDays');
|
|
|
|
const saveRuleBtn = document.getElementById('lifecycleSaveRuleBtn');
|
|
if (saveRuleBtn) {
|
|
saveRuleBtn.addEventListener('click', saveLifecycleRuleFromForm);
|
|
}
|
|
}
|
|
|
|
// readLifecycleRuleFromForm builds and validates a rule from the currently
|
|
// open edit form's DOM inputs. Returns { rule: null, error: '...' } when the
|
|
// form doesn't hold a valid rule, without mutating any state.
|
|
function readLifecycleRuleFromForm() {
|
|
const id = document.getElementById('lifecycleRuleId').value.trim();
|
|
const status = document.querySelector('input[name="lifecycleRuleStatus"]:checked').value;
|
|
const prefix = document.getElementById('lifecycleRulePrefix').value.trim();
|
|
|
|
const tags = {};
|
|
document.querySelectorAll('#lifecycleTagRows .lifecycle-tag-row').forEach(row => {
|
|
const key = row.querySelector('.lifecycle-tag-key').value.trim();
|
|
const value = row.querySelector('.lifecycle-tag-value').value.trim();
|
|
if (key) {
|
|
tags[key] = value;
|
|
}
|
|
});
|
|
|
|
const sizeGT = parseInt(document.getElementById('lifecycleRuleSizeGT').value, 10) || 0;
|
|
const sizeLT = parseInt(document.getElementById('lifecycleRuleSizeLT').value, 10) || 0;
|
|
|
|
const expireDaysEnabled = document.getElementById('lifecycleExpireDaysEnabled').checked;
|
|
const expireDateEnabled = document.getElementById('lifecycleExpireDateEnabled').checked;
|
|
const expirationDays = expireDaysEnabled ? (parseInt(document.getElementById('lifecycleExpireDays').value, 10) || 0) : 0;
|
|
const expirationDate = expireDateEnabled ? document.getElementById('lifecycleExpireDate').value : '';
|
|
|
|
const deleteMarker = document.getElementById('lifecycleDeleteMarker').checked;
|
|
|
|
const noncurrentEnabled = document.getElementById('lifecycleNoncurrentEnabled').checked;
|
|
const noncurrentDays = noncurrentEnabled ? (parseInt(document.getElementById('lifecycleNoncurrentDays').value, 10) || 0) : 0;
|
|
const noncurrentKeep = noncurrentEnabled ? (parseInt(document.getElementById('lifecycleNoncurrentKeep').value, 10) || 0) : 0;
|
|
|
|
const abortEnabled = document.getElementById('lifecycleAbortMPUEnabled').checked;
|
|
const abortDays = abortEnabled ? (parseInt(document.getElementById('lifecycleAbortMPUDays').value, 10) || 0) : 0;
|
|
|
|
if (expireDaysEnabled && expirationDays <= 0) {
|
|
return { rule: null, error: 'Enter a valid number of days for expiration.' };
|
|
}
|
|
if (expireDateEnabled && !expirationDate) {
|
|
return { rule: null, error: 'Enter a valid expiration date.' };
|
|
}
|
|
if (noncurrentEnabled && noncurrentDays <= 0 && noncurrentKeep <= 0) {
|
|
return { rule: null, error: 'Enter a number of days and/or a newest-versions-to-keep count for noncurrent version handling.' };
|
|
}
|
|
if (abortEnabled && abortDays <= 0) {
|
|
return { rule: null, error: 'Enter a valid number of days for aborting incomplete multipart uploads.' };
|
|
}
|
|
if (!expireDaysEnabled && !expireDateEnabled && !deleteMarker && !noncurrentEnabled && !abortEnabled) {
|
|
return { rule: null, error: 'Select at least one action for this rule.' };
|
|
}
|
|
// The server rejects this combination too; catching it here names the two
|
|
// fields instead of surfacing a 400 after the whole set is submitted.
|
|
if (deleteMarker && (expireDaysEnabled || expireDateEnabled)) {
|
|
return { rule: null, error: 'Removing expired object delete markers cannot be combined with an expiration.' };
|
|
}
|
|
if (sizeGT > 0 && sizeLT > 0 && sizeGT >= sizeLT) {
|
|
return { rule: null, error: '"Size greater than" must be less than "size less than".' };
|
|
}
|
|
|
|
return {
|
|
rule: {
|
|
id: id,
|
|
status: status,
|
|
prefix: prefix,
|
|
tags: tags,
|
|
size_greater_than: sizeGT > 0 ? sizeGT : null,
|
|
size_less_than: sizeLT > 0 ? sizeLT : null,
|
|
expiration_days: expirationDays > 0 ? expirationDays : null,
|
|
expiration_date: expirationDate || '',
|
|
expired_object_delete_marker: deleteMarker,
|
|
noncurrent_version_expiration_days: noncurrentDays > 0 ? noncurrentDays : null,
|
|
newer_noncurrent_versions: noncurrentKeep > 0 ? noncurrentKeep : null,
|
|
abort_multipart_days: abortDays > 0 ? abortDays : null,
|
|
},
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
function saveLifecycleRuleFromForm() {
|
|
const { rule, error } = readLifecycleRuleFromForm();
|
|
if (error) {
|
|
alert(error);
|
|
return;
|
|
}
|
|
|
|
if (lifecycleEditor.editingIndex === 'new') {
|
|
lifecycleEditor.rules.push(rule);
|
|
} else {
|
|
lifecycleEditor.rules[lifecycleEditor.editingIndex] = rule;
|
|
}
|
|
lifecycleEditor.editingIndex = null;
|
|
lifecycleEditor.editingRule = null;
|
|
renderLifecycleEditor();
|
|
}
|
|
|
|
// commitOpenLifecycleRuleForm folds an in-progress add/edit form into
|
|
// lifecycleEditor.rules before the whole rule set is sent to the server, so
|
|
// clicking the modal's "Save" without first clicking "Save rule" doesn't
|
|
// silently drop the open edit. Returns false (and leaves the form open) when
|
|
// the open form doesn't hold a valid rule, so the caller can abort the save.
|
|
function commitOpenLifecycleRuleForm() {
|
|
if (lifecycleEditor.editingIndex === null) {
|
|
return true;
|
|
}
|
|
|
|
const { rule, error } = readLifecycleRuleFromForm();
|
|
if (error) {
|
|
alert(error);
|
|
return false;
|
|
}
|
|
|
|
if (lifecycleEditor.editingIndex === 'new') {
|
|
lifecycleEditor.rules.push(rule);
|
|
} else {
|
|
lifecycleEditor.rules[lifecycleEditor.editingIndex] = rule;
|
|
}
|
|
lifecycleEditor.editingIndex = null;
|
|
lifecycleEditor.editingRule = null;
|
|
renderLifecycleEditor();
|
|
return true;
|
|
}
|
|
|
|
function goToPage(page) {
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('page', page);
|
|
window.location.href = url.toString();
|
|
}
|
|
|
|
function changePageSize() {
|
|
const pageSize = document.getElementById('pageSizeSelect').value;
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('pageSize', pageSize);
|
|
url.searchParams.set('page', '1');
|
|
window.location.href = url.toString();
|
|
}
|
|
|
|
function sortTable(column) {
|
|
const url = new URL(window.location);
|
|
const currentSort = url.searchParams.get('sortBy');
|
|
const currentOrder = url.searchParams.get('sortOrder') || 'asc';
|
|
|
|
let newOrder = 'asc';
|
|
if (currentSort === column && currentOrder === 'asc') {
|
|
newOrder = 'desc';
|
|
}
|
|
|
|
url.searchParams.set('sortBy', column);
|
|
url.searchParams.set('sortOrder', newOrder);
|
|
url.searchParams.set('page', '1');
|
|
window.location.href = url.toString();
|
|
}
|
|
|
|
function exportBucketList() {
|
|
// RFC 4180 compliant CSV escaping: escape double quotes by doubling them
|
|
function escapeCsvField(value) {
|
|
const str = String(value ?? '');
|
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
return '"' + str.replace(/"/g, '""') + '"';
|
|
}
|
|
return '"' + str + '"';
|
|
}
|
|
|
|
// Fetch all buckets from the API (not just the current page)
|
|
fetch(basePath('/api/s3/buckets'))
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert('Error exporting buckets: ' + data.error);
|
|
return;
|
|
}
|
|
|
|
const buckets = data.buckets || [];
|
|
const csvContent = "data:text/csv;charset=utf-8," +
|
|
"Name,Owner,Logical Size,Physical Size,Created,Quota,Versioning,Object Lock\n" +
|
|
buckets.map(b => [
|
|
escapeCsvField(b.name),
|
|
escapeCsvField(b.owner),
|
|
escapeCsvField(formatBytes(b.logical_size)),
|
|
escapeCsvField(formatBytes(b.physical_size)),
|
|
escapeCsvField(b.created_at),
|
|
escapeCsvField(b.quota_enabled ? formatBytes(b.quota) : 'No quota'),
|
|
escapeCsvField(b.versioning_status || 'Not configured'),
|
|
escapeCsvField(b.object_lock_enabled ? 'Enabled' : 'Not configured')
|
|
].join(',')).join("\n");
|
|
|
|
const encodedUri = encodeURI(csvContent);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", encodedUri);
|
|
link.setAttribute("download", "buckets.csv");
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error exporting buckets: ' + error.message);
|
|
});
|
|
}
|
|
</script>
|
|
}
|
|
|
|
// Helper functions for template
|
|
func getQuotaStatusColor(used, quota int64, enabled bool) string {
|
|
if !enabled || quota <= 0 {
|
|
return "secondary"
|
|
}
|
|
|
|
percentage := float64(used) / float64(quota) * 100
|
|
if percentage >= 90 {
|
|
return "danger"
|
|
} else if percentage >= 75 {
|
|
return "warning"
|
|
} else {
|
|
return "success"
|
|
}
|
|
}
|
|
|
|
func getQuotaInMB(quotaBytes int64) int64 {
|
|
if quotaBytes < 0 {
|
|
quotaBytes = -quotaBytes // Handle disabled quotas (negative values)
|
|
}
|
|
return quotaBytes / (1024 * 1024)
|
|
} |