Files
seaweedfs/weed/admin/static/js/s3tables.js
T
Chris LuandGitHub 69cc2869ad Fixes from the review of the admin bucket policy UI (#10907)
* 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.
2026-08-24 00:52:01 -07:00

1175 lines
48 KiB
JavaScript

/**
* Shared S3 Tables functionality for the SeaweedFS Admin Dashboard.
*/
// URL prefix helper for subdirectory deployment
function s3tBasePath(path) {
return (window.__BASE_PATH__ || '') + path;
}
// Shared Modals
let s3tablesBucketDeleteModal = null;
let s3tablesBucketPolicyModal = null;
let s3tablesTableDeleteModal = null;
let s3tablesTablePolicyModal = null;
let s3tablesTagsModal = null;
let icebergTableDeleteModal = null;
// True only once a bucket/table policy GET has actually completed
// successfully (a genuinely empty policy counts). Guards the Save handlers
// below: a failed GET must not let a Save serialize the editor's cleared-out
// placeholder state as a real "Statement: []" document and overwrite
// whatever is actually stored.
let s3tablesBucketPolicyLoaded = false;
let s3tablesTablePolicyLoaded = false;
// Bumped on every bucket/table policy load; a response only gets applied if
// its captured sequence number still matches. Without this, opening one
// resource's policy dialog and then another's before the first GET resolves
// lets the late response overwrite the second resource's textarea/editor
// state and mark it loaded, so a subsequent Save would push the first
// resource's policy onto the second resource.
let s3tablesBucketPolicyRequestSeq = 0;
let s3tablesTablePolicyRequestSeq = 0;
// True while a policy PUT/DELETE is in flight, so a double-click - or Save
// and Delete fired in quick succession - can't send overlapping mutations.
// Same pattern as the classic bucket modal in s3_buckets.templ.
let s3tablesBucketPolicyMutationInFlight = false;
let s3tablesTablePolicyMutationInFlight = false;
function setS3TablesBucketPolicyMutationInFlight(inFlight) {
s3tablesBucketPolicyMutationInFlight = inFlight;
const save = document.getElementById('s3tablesBucketPolicySaveBtn');
const del = document.getElementById('s3tablesBucketPolicyDeleteBtn');
if (save) save.disabled = inFlight;
if (del) del.disabled = inFlight;
}
function setS3TablesTablePolicyMutationInFlight(inFlight) {
s3tablesTablePolicyMutationInFlight = inFlight;
const save = document.getElementById('s3tablesTablePolicySaveBtn');
const del = document.getElementById('s3tablesTablePolicyDeleteBtn');
if (save) save.disabled = inFlight;
if (del) del.disabled = inFlight;
}
// The dialog identity captured when a mutation started, so a completion
// that lands after the shared modal moved on to a different resource can't
// alert against, hide, or reload over that other resource's state.
function currentS3TablesBucketPolicyTarget() {
return document.getElementById('s3tablesBucketPolicyArn').value;
}
function currentS3TablesTablePolicyTarget() {
return document.getElementById('s3tablesTablePolicyBucketArn').value + '\n' +
document.getElementById('s3tablesTablePolicyNamespace').value + '\n' +
document.getElementById('s3tablesTablePolicyName').value;
}
function getCSRFToken() {
const tokenMeta = document.querySelector('meta[name="csrf-token"]');
if (!tokenMeta) {
return '';
}
return tokenMeta.getAttribute('content') || '';
}
function s3tWriteHeaders(extra) {
const headers = Object.assign({}, extra || {});
const csrfToken = getCSRFToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
return headers;
}
/**
* Initialize S3 Tables Buckets Page
*/
function initS3TablesBuckets() {
s3tablesBucketDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesBucketModal'));
s3tablesBucketPolicyModal = new bootstrap.Modal(document.getElementById('s3tablesBucketPolicyModal'));
s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal'));
registerS3TablesBucketPolicyEditor('');
setupPolicyEditor('s3tablesBucket');
const ownerSelect = document.getElementById('s3tablesBucketOwner');
if (ownerSelect) {
document.getElementById('createS3TablesBucketModal').addEventListener('show.bs.modal', async function () {
if (ownerSelect.options.length <= 1) {
try {
const response = await fetch(s3tBasePath('/api/users'));
const data = await response.json();
const users = data.users || [];
users.forEach(user => {
const option = document.createElement('option');
option.value = user.username;
option.textContent = user.username;
ownerSelect.appendChild(option);
});
} catch (error) {
console.error('Error fetching users for owner dropdown:', error);
ownerSelect.innerHTML = '<option value="">No owner (admin-only access)</option>';
ownerSelect.selectedIndex = 0;
}
}
});
}
const bucketNameInput = document.getElementById('s3tablesBucketName');
if (bucketNameInput) {
bucketNameInput.addEventListener('input', function () {
applyS3TablesBucketNameValidity(this, true);
});
}
document.querySelectorAll('.s3tables-delete-bucket-btn').forEach(button => {
button.addEventListener('click', function () {
document.getElementById('deleteS3TablesBucketName').textContent = this.dataset.bucketName || '';
document.getElementById('deleteS3TablesBucketModal').dataset.bucketArn = this.dataset.bucketArn || '';
s3tablesBucketDeleteModal.show();
});
});
document.querySelectorAll('.s3tables-bucket-policy-btn').forEach(button => {
button.addEventListener('click', function () {
const bucketArn = this.dataset.bucketArn || '';
document.getElementById('s3tablesBucketPolicyArn').value = bucketArn;
registerS3TablesBucketPolicyEditor(bucketArn);
loadS3TablesBucketPolicy(bucketArn);
s3tablesBucketPolicyModal.show();
});
});
document.querySelectorAll('.s3tables-tags-btn').forEach(button => {
button.addEventListener('click', function () {
const resourceArn = this.dataset.resourceArn || '';
openS3TablesTags(resourceArn);
});
});
const createForm = document.getElementById('createS3TablesBucketForm');
if (createForm) {
createForm.addEventListener('submit', async function (e) {
e.preventDefault();
const bucketNameInput = document.getElementById('s3tablesBucketName');
const name = bucketNameInput.value.trim();
const nameError = applyS3TablesBucketNameValidity(bucketNameInput, false);
if (nameError) {
bucketNameInput.reportValidity();
return;
}
const owner = ownerSelect.value;
const tagsInput = document.getElementById('s3tablesBucketTags').value.trim();
const tags = parseTagsInput(tagsInput);
if (tags === null) return;
const formatInput = document.querySelector('#s3tablesBucketFormatPicker input[name="format"]:checked');
const payload = { name: name, tags: tags, owner: owner, format: formatInput ? formatInput.value : 'ICEBERG' };
try {
const response = await fetch(s3tBasePath('/api/s3tables/buckets'), {
method: 'POST',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to create bucket');
return;
}
alert('Bucket created successfully');
location.reload();
} catch (error) {
alert('Failed to create bucket: ' + error.message);
}
});
}
// The endpoint is the whole reason the format matters, so show it changing
// rather than making the operator work it out after the fact.
const formatPicker = document.getElementById('s3tablesBucketFormatPicker');
const formatHint = document.getElementById('s3tablesBucketFormatHint');
if (formatPicker && formatHint) {
const icebergPort = formatHint.dataset.icebergPort;
const lancePort = formatHint.dataset.lancePort;
const origin = window.location.protocol + '//' + window.location.hostname;
const describeEndpoint = function () {
const chosen = formatPicker.querySelector('input[name="format"]:checked');
const isLance = chosen && chosen.value === 'LANCE';
const port = isLance ? lancePort : icebergPort;
const name = (document.getElementById('s3tablesBucketName').value || '').trim();
// The bucket name is whatever the operator is typing, so it goes in
// as text. Building this with innerHTML would run their input.
formatHint.textContent = '';
if (!port || port === '0') {
formatHint.appendChild(document.createTextNode('A bucket holds one format. Tables of the other are refused. '));
const warning = document.createElement('span');
warning.className = 'text-warning';
warning.textContent = 'No server is running for this format.';
formatHint.appendChild(warning);
return;
}
const path = isLance
? '/v1/namespace/' + (name || '<bucket>') + '/list'
: '/v1/' + (name || '<bucket>') + '/namespaces';
formatHint.appendChild(document.createTextNode('Clients reach this bucket at '));
const endpoint = document.createElement('code');
endpoint.textContent = origin + ':' + port + path;
formatHint.appendChild(endpoint);
};
formatPicker.addEventListener('change', describeEndpoint);
const bucketNameField = document.getElementById('s3tablesBucketName');
if (bucketNameField) {
bucketNameField.addEventListener('input', describeEndpoint);
}
describeEndpoint();
}
// The banner prints localhost server-side; the browser knows the real host.
document.querySelectorAll('.s3tables-origin').forEach(function (el) {
el.textContent = window.location.protocol + '//' + window.location.hostname + ':' + el.dataset.port;
});
const policyForm = document.getElementById('s3tablesBucketPolicyForm');
if (policyForm) {
// Saves go through the Save button only; implicit form submission
// (Enter in a single-line editor input) must never PUT half-built
// state, which the backend would store verbatim.
policyForm.addEventListener('submit', function (e) { e.preventDefault(); });
document.getElementById('s3tablesBucketPolicySaveBtn').addEventListener('click', saveS3TablesBucketPolicy);
}
const tagsForm = document.getElementById('s3tablesTagsForm');
if (tagsForm) {
tagsForm.addEventListener('submit', async function (e) {
e.preventDefault();
const resourceArn = document.getElementById('s3tablesTagsResourceArn').value;
const tags = parseTagsInput(document.getElementById('s3tablesTagsInput').value.trim());
if (tags === null || Object.keys(tags).length === 0) {
alert('Please provide tags to update');
return;
}
await updateS3TablesTags(resourceArn, tags);
});
}
}
/**
* Initialize S3 Tables Tables Page
*/
function initS3TablesTables() {
s3tablesTableDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesTableModal'));
s3tablesTablePolicyModal = new bootstrap.Modal(document.getElementById('s3tablesTablePolicyModal'));
s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal'));
registerS3TablesTablePolicyEditor('', '', '');
setupPolicyEditor('s3tablesTable');
const dataContainer = document.getElementById('s3tables-tables-content');
const dataBucketArn = dataContainer.dataset.bucketArn || '';
const dataNamespace = dataContainer.dataset.namespace || '';
document.querySelectorAll('.s3tables-delete-table-btn').forEach(button => {
button.addEventListener('click', function () {
document.getElementById('deleteS3TablesTableName').textContent = this.dataset.tableName || '';
document.getElementById('deleteS3TablesTableModal').dataset.tableName = this.dataset.tableName || '';
s3tablesTableDeleteModal.show();
});
});
document.querySelectorAll('.s3tables-table-policy-btn').forEach(button => {
button.addEventListener('click', function () {
document.getElementById('s3tablesTablePolicyBucketArn').value = dataBucketArn;
document.getElementById('s3tablesTablePolicyNamespace').value = dataNamespace;
document.getElementById('s3tablesTablePolicyName').value = this.dataset.tableName || '';
registerS3TablesTablePolicyEditor(dataBucketArn, dataNamespace, this.dataset.tableName || '');
loadS3TablesTablePolicy(dataBucketArn, dataNamespace, this.dataset.tableName || '');
s3tablesTablePolicyModal.show();
});
});
document.querySelectorAll('.s3tables-tags-btn').forEach(button => {
button.addEventListener('click', function () {
const resourceArn = this.dataset.resourceArn || '';
openS3TablesTags(resourceArn);
});
});
const createForm = document.getElementById('createS3TablesTableForm');
if (createForm) {
const tableNameInput = document.getElementById('s3tablesTableName');
if (tableNameInput) {
tableNameInput.addEventListener('input', function () {
applyS3TablesTableNameValidity(this, true);
});
}
createForm.addEventListener('submit', async function (e) {
e.preventDefault();
const tableNameInput = document.getElementById('s3tablesTableName');
const name = tableNameInput.value.trim();
const nameError = applyS3TablesTableNameValidity(tableNameInput, false);
if (nameError) {
tableNameInput.reportValidity();
return;
}
const format = document.getElementById('s3tablesTableFormat').value;
const metadataText = document.getElementById('s3tablesTableMetadata').value.trim();
const tagsInput = document.getElementById('s3tablesTableTags').value.trim();
const tags = parseTagsInput(tagsInput);
if (tags === null) return;
let metadata = null;
if (metadataText) {
try {
metadata = JSON.parse(metadataText);
} catch (error) {
alert('Invalid metadata JSON');
return;
}
}
const payload = { bucket_arn: dataBucketArn, namespace: dataNamespace, name: name, format: format, tags: tags };
if (metadata) {
payload.metadata = metadata;
}
try {
const response = await fetch(s3tBasePath('/api/s3tables/tables'), {
method: 'POST',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to create table');
return;
}
alert('Table created');
location.reload();
} catch (error) {
alert('Failed to create table: ' + error.message);
}
});
}
const policyForm = document.getElementById('s3tablesTablePolicyForm');
if (policyForm) {
// Same Enter-must-not-submit rule as the bucket policy form.
policyForm.addEventListener('submit', function (e) { e.preventDefault(); });
document.getElementById('s3tablesTablePolicySaveBtn').addEventListener('click', saveS3TablesTablePolicy);
}
const tagsForm = document.getElementById('s3tablesTagsForm');
if (tagsForm) {
tagsForm.addEventListener('submit', async function (e) {
e.preventDefault();
const resourceArn = document.getElementById('s3tablesTagsResourceArn').value;
const tags = parseTagsInput(document.getElementById('s3tablesTagsInput').value.trim());
if (tags === null || Object.keys(tags).length === 0) {
alert('Please provide tags to update');
return;
}
await updateS3TablesTags(resourceArn, tags);
});
}
}
/**
* Initialize Iceberg Namespaces Page
*/
function initIcebergNamespaces() {
const container = document.getElementById('iceberg-namespaces-content');
if (!container) return;
const bucketArn = container.dataset.bucketArn || '';
const catalogName = container.dataset.catalogName || '';
const namespaceInput = document.getElementById('icebergNamespaceName');
if (namespaceInput) {
namespaceInput.addEventListener('input', function () {
applyS3TablesNamespaceNameValidity(this, true);
});
}
const createForm = document.getElementById('createIcebergNamespaceForm');
if (createForm) {
createForm.addEventListener('submit', async function (e) {
e.preventDefault();
const namespaceInput = document.getElementById('icebergNamespaceName');
const name = namespaceInput.value.trim();
const nameError = applyS3TablesNamespaceNameValidity(namespaceInput, false);
if (nameError) {
namespaceInput.reportValidity();
return;
}
try {
const response = await fetch(s3tBasePath('/api/s3tables/namespaces'), {
method: 'POST',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ bucket_arn: bucketArn, name: name })
});
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to create namespace');
return;
}
alert('Namespace created');
location.reload();
} catch (error) {
alert('Failed to create namespace: ' + error.message);
}
});
}
initIcebergNamespaceTree(container, bucketArn, catalogName);
}
function initIcebergNamespaceTree(container, bucketArn, catalogName) {
const nodes = container.querySelectorAll('.iceberg-namespace-collapse');
nodes.forEach(node => {
node.addEventListener('show.bs.collapse', async function () {
if (node.dataset.loaded === 'true') return;
node.textContent = 'Loading...';
node.className = 'text-muted small';
try {
await loadIcebergNamespaceTables(node, bucketArn, catalogName);
node.dataset.loaded = 'true';
} catch (error) {
node.textContent = 'Failed to load. Collapse and expand to retry.';
node.className = 'text-danger small';
console.error('Error loading namespace tables:', error);
}
});
});
}
async function loadIcebergNamespaceTables(node, bucketArn, catalogName) {
const namespace = node.dataset.namespace || '';
if (!bucketArn || !namespace) {
node.textContent = 'No namespace data available.';
node.className = 'text-muted small';
throw new Error('Missing bucket or namespace');
}
try {
const query = new URLSearchParams({ bucket: bucketArn, namespace: namespace });
const response = await fetch(s3tBasePath(`/api/s3tables/tables?${query.toString()}`));
const data = await response.json();
if (!response.ok) {
node.textContent = data.error || 'Failed to load tables';
node.className = 'text-danger small';
throw new Error(data.error || 'Failed to load tables');
}
const tables = data.tables || [];
if (tables.length === 0) {
node.textContent = 'No tables found.';
node.className = 'text-muted small ms-3';
return;
}
node.innerHTML = '';
const list = document.createElement('ul');
list.className = 'list-group list-group-flush ms-3';
tables.forEach(table => {
const item = document.createElement('li');
item.className = 'list-group-item py-1';
const link = document.createElement('a');
link.className = 'text-decoration-none';
link.href = s3tBasePath(`/object-store/s3tables/buckets/${encodeURIComponent(catalogName)}/namespaces/${encodeURIComponent(namespace)}/tables/${encodeURIComponent(table.name)}`);
const icon = document.createElement('i');
icon.className = 'fas fa-table text-primary me-2';
link.appendChild(icon);
const nameSpan = document.createElement('span');
nameSpan.textContent = table.name;
link.appendChild(nameSpan);
item.appendChild(link);
list.appendChild(item);
});
node.appendChild(list);
} catch (error) {
if (!node.textContent) {
node.textContent = 'Failed to load tables: ' + (error.message || 'Unknown error');
node.className = 'text-danger small';
}
throw error;
}
}
/**
* Initialize Iceberg Tables Page
*/
function initIcebergTables() {
const container = document.getElementById('iceberg-tables-content');
if (!container) return;
const bucketArn = container.dataset.bucketArn || '';
const namespace = container.dataset.namespace || '';
initIcebergDeleteModal();
const createForm = document.getElementById('createIcebergTableForm');
if (createForm) {
const tableNameInput = document.getElementById('icebergTableName');
if (tableNameInput) {
tableNameInput.addEventListener('input', function () {
applyS3TablesTableNameValidity(this, true);
});
}
createForm.addEventListener('submit', async function (e) {
e.preventDefault();
const tableNameInput = document.getElementById('icebergTableName');
const name = tableNameInput.value.trim();
const nameError = applyS3TablesTableNameValidity(tableNameInput, false);
if (nameError) {
tableNameInput.reportValidity();
return;
}
const format = document.getElementById('icebergTableFormat').value;
const metadataText = document.getElementById('icebergTableMetadata').value.trim();
const tagsInput = document.getElementById('icebergTableTags').value.trim();
const tags = parseTagsInput(tagsInput);
if (tags === null) return;
let metadata = null;
if (metadataText) {
try {
metadata = JSON.parse(metadataText);
} catch (error) {
alert('Invalid metadata JSON');
return;
}
}
const payload = { bucket_arn: bucketArn, namespace: namespace, name: name, format: format, tags: tags };
if (metadata) {
payload.metadata = metadata;
}
try {
const response = await fetch(s3tBasePath('/api/s3tables/tables'), {
method: 'POST',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to create table');
return;
}
alert('Table created');
location.reload();
} catch (error) {
alert('Failed to create table: ' + error.message);
}
});
}
}
/**
* Initialize Iceberg Table Details Page
*/
function initIcebergTableDetails() {
initIcebergDeleteModal();
}
function initIcebergDeleteModal() {
const modalEl = document.getElementById('deleteIcebergTableModal');
if (!modalEl) return;
icebergTableDeleteModal = new bootstrap.Modal(modalEl);
document.querySelectorAll('.iceberg-delete-table-btn').forEach(button => {
button.addEventListener('click', function () {
modalEl.dataset.bucketArn = this.dataset.bucketArn || '';
modalEl.dataset.namespace = this.dataset.namespace || '';
modalEl.dataset.tableName = this.dataset.tableName || '';
modalEl.dataset.catalogName = this.dataset.catalogName || '';
document.getElementById('deleteIcebergTableName').textContent = this.dataset.tableName || '';
document.getElementById('deleteIcebergTableVersion').value = '';
icebergTableDeleteModal.show();
});
});
}
// Global scope functions used by onclick handlers
async function deleteS3TablesBucket() {
const bucketArn = document.getElementById('deleteS3TablesBucketModal').dataset.bucketArn;
if (!bucketArn) return;
try {
const response = await fetch(s3tBasePath(`/api/s3tables/buckets?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to delete bucket');
return;
}
alert('Bucket deleted');
location.reload();
} catch (error) {
alert('Failed to delete bucket: ' + error.message);
}
}
// Shared visual policy editor (weed/admin/static/js/policy_editor.js),
// configured for the S3 Tables policy engine: only s3tables: action
// suggestions, resource suggestions pinned to the open resource's ARN, and
// no NotResource/NotPrincipal modes - the s3tables evaluator has no such
// fields and would silently drop them (see s3tables/permissions.go).
// Re-registered on every dialog open so the suggestions track the resource.
function registerS3TablesBucketPolicyEditor(bucketArn) {
registerPolicyEditor('s3tablesBucket', {
textareaId: 's3tablesBucketPolicyText',
actionDatalistId: 's3tablesPolicyActionSuggestions',
allowNegation: false,
resourceSuggestions: bucketArn ? [bucketArn, bucketArn + '/table/*'] : null
});
}
function registerS3TablesTablePolicyEditor(bucketArn, namespace, name) {
registerPolicyEditor('s3tablesTable', {
textareaId: 's3tablesTablePolicyText',
actionDatalistId: 's3tablesPolicyActionSuggestions',
allowNegation: false,
resourceSuggestions: bucketArn && namespace && name ? [bucketArn + '/table/' + namespace + '/' + name] : null
});
}
async function loadS3TablesBucketPolicy(bucketArn) {
const requestSeq = ++s3tablesBucketPolicyRequestSeq;
document.getElementById('s3tablesBucketPolicyText').value = '';
s3tablesBucketPolicyLoaded = false;
// Reset the structured editor immediately too, so a still-open Editor
// tab doesn't keep showing the previously loaded resource's statements
// while this fetch is in flight.
loadPolicyTextareaIntoEditor('s3tablesBucket');
if (bucketArn) {
let policyText = '';
let loadError = null;
try {
const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`));
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || ('HTTP ' + response.status));
}
if (data.policy) {
policyText = data.policy;
}
} catch (error) {
loadError = error;
}
// A newer load (a different bucket, or this one reopened) has since
// superseded this response - don't let it touch the shared textarea,
// the editor state, or the loaded flag.
if (requestSeq !== s3tablesBucketPolicyRequestSeq) return;
if (loadError) {
console.error('Failed to load bucket policy', loadError);
alert('Failed to load bucket policy: ' + loadError.message + '. Close and reopen this dialog to try again.');
return;
}
document.getElementById('s3tablesBucketPolicyText').value = policyText;
}
if (requestSeq !== s3tablesBucketPolicyRequestSeq) return;
s3tablesBucketPolicyLoaded = true;
loadPolicyTextareaIntoEditor('s3tablesBucket');
}
async function saveS3TablesBucketPolicy() {
if (s3tablesBucketPolicyMutationInFlight) return;
if (!s3tablesBucketPolicyLoaded) {
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
return;
}
if (!commitPolicyActiveTab('s3tablesBucket')) return;
const bucketArn = currentS3TablesBucketPolicyTarget();
const policy = document.getElementById('s3tablesBucketPolicyText').value.trim();
if (!policy || !policyTextHasStatements(policy)) {
alert('Add at least one statement, or use Delete Policy to remove the policy.');
return;
}
setS3TablesBucketPolicyMutationInFlight(true);
try {
const response = await fetch(s3tBasePath('/api/s3tables/bucket-policy'), {
method: 'PUT',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ bucket_arn: bucketArn, policy: policy })
});
const data = await response.json();
setS3TablesBucketPolicyMutationInFlight(false);
const stillCurrent = bucketArn === currentS3TablesBucketPolicyTarget();
if (!response.ok) {
if (stillCurrent) {
alert(data.error || 'Failed to update policy');
} else {
console.error('Error saving policy for ' + bucketArn + ' (no longer the open resource): ' + (data.error || 'unknown error'));
}
return;
}
if (!stillCurrent) return;
s3tablesBucketPolicyModal.hide();
// Reload so the Policy column reflects the change.
setTimeout(() => location.reload(), 500);
} catch (error) {
setS3TablesBucketPolicyMutationInFlight(false);
if (bucketArn === currentS3TablesBucketPolicyTarget()) {
alert('Failed to update policy: ' + error.message);
} else {
console.error('Error saving policy for ' + bucketArn + ': ' + error.message);
}
}
}
async function saveS3TablesTablePolicy() {
if (s3tablesTablePolicyMutationInFlight) return;
if (!s3tablesTablePolicyLoaded) {
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
return;
}
if (!commitPolicyActiveTab('s3tablesTable')) return;
const target = currentS3TablesTablePolicyTarget();
const policy = document.getElementById('s3tablesTablePolicyText').value.trim();
if (!policy || !policyTextHasStatements(policy)) {
alert('Add at least one statement, or use Delete Policy to remove the policy.');
return;
}
setS3TablesTablePolicyMutationInFlight(true);
try {
const response = await fetch(s3tBasePath('/api/s3tables/table-policy'), {
method: 'PUT',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
bucket_arn: document.getElementById('s3tablesTablePolicyBucketArn').value,
namespace: document.getElementById('s3tablesTablePolicyNamespace').value,
name: document.getElementById('s3tablesTablePolicyName').value,
policy: policy
})
});
const data = await response.json();
setS3TablesTablePolicyMutationInFlight(false);
const stillCurrent = target === currentS3TablesTablePolicyTarget();
if (!response.ok) {
if (stillCurrent) {
alert(data.error || 'Failed to update policy');
} else {
console.error('Error saving table policy (no longer the open resource): ' + (data.error || 'unknown error'));
}
return;
}
if (!stillCurrent) return;
s3tablesTablePolicyModal.hide();
setTimeout(() => location.reload(), 500);
} catch (error) {
setS3TablesTablePolicyMutationInFlight(false);
if (target === currentS3TablesTablePolicyTarget()) {
alert('Failed to update policy: ' + error.message);
} else {
console.error('Error saving table policy: ' + error.message);
}
}
}
async function deleteS3TablesBucketPolicy() {
const bucketArn = currentS3TablesBucketPolicyTarget();
if (!bucketArn) return;
if (s3tablesBucketPolicyMutationInFlight) return;
if (!s3tablesBucketPolicyLoaded) {
alert('The current policy has not finished loading. Close and reopen this dialog before deleting.');
return;
}
if (!confirm('Delete this table bucket policy? This cannot be undone.')) return;
setS3TablesBucketPolicyMutationInFlight(true);
try {
const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
setS3TablesBucketPolicyMutationInFlight(false);
const stillCurrent = bucketArn === currentS3TablesBucketPolicyTarget();
if (!response.ok) {
if (stillCurrent) {
alert(data.error || 'Failed to delete policy');
} else {
console.error('Error deleting policy for ' + bucketArn + ' (no longer the open resource): ' + (data.error || 'unknown error'));
}
return;
}
if (!stillCurrent) return;
s3tablesBucketPolicyModal.hide();
setTimeout(() => location.reload(), 500);
} catch (error) {
setS3TablesBucketPolicyMutationInFlight(false);
if (bucketArn === currentS3TablesBucketPolicyTarget()) {
alert('Failed to delete policy: ' + error.message);
} else {
console.error('Error deleting policy for ' + bucketArn + ': ' + error.message);
}
}
}
async function deleteS3TablesTable() {
const dataContainer = document.getElementById('s3tables-tables-content');
const dataBucketArn = dataContainer.dataset.bucketArn || '';
const dataNamespace = dataContainer.dataset.namespace || '';
const tableName = document.getElementById('deleteS3TablesTableModal').dataset.tableName;
const versionToken = document.getElementById('deleteS3TablesTableVersion').value.trim();
if (!tableName) return;
const query = new URLSearchParams({
bucket: dataBucketArn,
namespace: dataNamespace,
name: tableName
});
if (versionToken) {
query.set('version', versionToken);
}
try {
const response = await fetch(s3tBasePath(`/api/s3tables/tables?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to delete table');
return;
}
alert('Table deleted');
location.reload();
} catch (error) {
alert('Failed to delete table: ' + error.message);
}
}
async function deleteIcebergTable() {
const modalEl = document.getElementById('deleteIcebergTableModal');
if (!modalEl) return;
const bucketArn = modalEl.dataset.bucketArn || '';
const namespace = modalEl.dataset.namespace || '';
const tableName = modalEl.dataset.tableName || '';
const catalogName = modalEl.dataset.catalogName || '';
const versionToken = document.getElementById('deleteIcebergTableVersion').value.trim();
if (!bucketArn || !namespace || !tableName) return;
const query = new URLSearchParams({
bucket: bucketArn,
namespace: namespace,
name: tableName
});
if (versionToken) {
query.set('version', versionToken);
}
try {
const response = await fetch(s3tBasePath(`/api/s3tables/tables?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to drop table');
return;
}
alert('Table dropped');
const isDetailsPage = window.location.pathname.includes('/tables/') && window.location.pathname.includes('/namespaces/');
if (isDetailsPage && catalogName && namespace) {
window.location.href = s3tBasePath(`/object-store/s3tables/buckets/${encodeURIComponent(catalogName)}/namespaces/${encodeURIComponent(namespace)}/tables`);
} else {
location.reload();
}
} catch (error) {
alert('Failed to drop table: ' + error.message);
}
}
async function loadS3TablesTablePolicy(bucketArn, namespace, name) {
const requestSeq = ++s3tablesTablePolicyRequestSeq;
document.getElementById('s3tablesTablePolicyText').value = '';
s3tablesTablePolicyLoaded = false;
// Reset the structured editor immediately too, so a still-open Editor
// tab doesn't keep showing the previously loaded resource's statements
// while this fetch is in flight.
loadPolicyTextareaIntoEditor('s3tablesTable');
if (bucketArn && namespace && name) {
const query = new URLSearchParams({ bucket: bucketArn, namespace: namespace, name: name });
let policyText = '';
let loadError = null;
try {
const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`));
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || ('HTTP ' + response.status));
}
if (data.policy) {
policyText = data.policy;
}
} catch (error) {
loadError = error;
}
// A newer load (a different table, or this one reopened) has since
// superseded this response - don't let it touch the shared textarea,
// the editor state, or the loaded flag.
if (requestSeq !== s3tablesTablePolicyRequestSeq) return;
if (loadError) {
console.error('Failed to load table policy', loadError);
alert('Failed to load table policy: ' + loadError.message + '. Close and reopen this dialog to try again.');
return;
}
document.getElementById('s3tablesTablePolicyText').value = policyText;
}
if (requestSeq !== s3tablesTablePolicyRequestSeq) return;
s3tablesTablePolicyLoaded = true;
loadPolicyTextareaIntoEditor('s3tablesTable');
}
async function deleteS3TablesTablePolicy() {
if (s3tablesTablePolicyMutationInFlight) return;
if (!s3tablesTablePolicyLoaded) {
alert('The current policy has not finished loading. Close and reopen this dialog before deleting.');
return;
}
const target = currentS3TablesTablePolicyTarget();
const query = new URLSearchParams({
bucket: document.getElementById('s3tablesTablePolicyBucketArn').value,
namespace: document.getElementById('s3tablesTablePolicyNamespace').value,
name: document.getElementById('s3tablesTablePolicyName').value
});
if (!confirm('Delete the policy for table ' + document.getElementById('s3tablesTablePolicyName').value + '? This cannot be undone.')) return;
setS3TablesTablePolicyMutationInFlight(true);
try {
const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
const data = await response.json();
setS3TablesTablePolicyMutationInFlight(false);
const stillCurrent = target === currentS3TablesTablePolicyTarget();
if (!response.ok) {
if (stillCurrent) {
alert(data.error || 'Failed to delete policy');
} else {
console.error('Error deleting table policy (no longer the open resource): ' + (data.error || 'unknown error'));
}
return;
}
if (!stillCurrent) return;
s3tablesTablePolicyModal.hide();
setTimeout(() => location.reload(), 500);
} catch (error) {
setS3TablesTablePolicyMutationInFlight(false);
if (target === currentS3TablesTablePolicyTarget()) {
alert('Failed to delete policy: ' + error.message);
} else {
console.error('Error deleting table policy: ' + error.message);
}
}
}
function isLowercaseLetterOrDigit(ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9');
}
function s3TablesBucketNameError(name) {
if (!name) {
return 'Bucket name is required';
}
if (name.length < 3 || name.length > 63) {
return 'Bucket name must be between 3 and 63 characters';
}
if (!isLowercaseLetterOrDigit(name[0])) {
return 'Bucket name must start with a letter or digit';
}
if (!isLowercaseLetterOrDigit(name[name.length - 1])) {
return 'Bucket name must end with a letter or digit';
}
for (let i = 0; i < name.length; i++) {
const ch = name[i];
if (isLowercaseLetterOrDigit(ch) || ch === '-') {
continue;
}
return 'Bucket name can only contain lowercase letters, numbers, and hyphens';
}
const reservedPrefixes = ['xn--', 'sthree-', 'amzn-s3-demo-', 'aws'];
for (const prefix of reservedPrefixes) {
if (name.startsWith(prefix)) {
return `Bucket name cannot start with reserved prefix: ${prefix}`;
}
}
const reservedSuffixes = ['-s3alias', '--ol-s3', '--x-s3', '--table-s3'];
for (const suffix of reservedSuffixes) {
if (name.endsWith(suffix)) {
return `Bucket name cannot end with reserved suffix: ${suffix}`;
}
}
return '';
}
function s3TablesNamespaceNameError(name) {
if (!name) {
return 'Namespace name is required';
}
if (name.length < 1 || name.length > 255) {
return 'Namespace name must be between 1 and 255 characters';
}
if (name === '.' || name === '..') {
return "namespace name cannot be '.' or '..'";
}
if (name.includes('/')) {
return "namespace name cannot contain '/'";
}
const parts = name.split('.');
for (const part of parts) {
if (!part) {
return 'namespace levels cannot be empty';
}
if (!isLowercaseLetterOrDigit(part[0])) {
return 'Namespace name must start with a letter or digit';
}
if (!isLowercaseLetterOrDigit(part[part.length - 1])) {
return 'Namespace name must end with a letter or digit';
}
for (const ch of part) {
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
continue;
}
return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
}
if (part.startsWith('aws')) {
return "namespace name cannot start with reserved prefix 'aws'";
}
}
return '';
}
function s3TablesTableNameError(name) {
if (!name) {
return 'Table name is required';
}
if (name.length < 1 || name.length > 255) {
return 'Table name must be between 1 and 255 characters';
}
if (name === '.' || name === '..' || name.includes('/')) {
return "invalid table name: cannot be '.', '..' or contain '/'";
}
if (!isLowercaseLetterOrDigit(name[0])) {
return 'Table name must start with a letter or digit';
}
for (const ch of name) {
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
continue;
}
return "invalid table name: only 'a-z', '0-9', and '_' are allowed";
}
return '';
}
function applyS3TablesBucketNameValidity(input, allowEmpty) {
const name = input.value.trim();
if (allowEmpty && name === '') {
input.setCustomValidity('');
return '';
}
const message = s3TablesBucketNameError(name);
input.setCustomValidity(message);
return message;
}
function applyS3TablesNamespaceNameValidity(input, allowEmpty) {
const name = input.value.trim();
if (allowEmpty && name === '') {
input.setCustomValidity('');
return '';
}
const message = s3TablesNamespaceNameError(name);
input.setCustomValidity(message);
return message;
}
function applyS3TablesTableNameValidity(input, allowEmpty) {
const name = input.value.trim();
if (allowEmpty && name === '') {
input.setCustomValidity('');
return '';
}
const message = s3TablesTableNameError(name);
input.setCustomValidity(message);
return message;
}
function parseTagsInput(input) {
if (!input) return {};
const tags = {};
const maxTags = 10;
const maxKeyLength = 128;
const maxValueLength = 256;
const parts = input.split(',');
for (const part of parts) {
const trimmedPart = part.trim();
if (!trimmedPart) continue;
const idx = trimmedPart.indexOf('=');
if (idx <= 0) {
alert('Invalid tag format. Use key=value, and key cannot be empty.');
return null;
}
const key = trimmedPart.slice(0, idx).trim();
const value = trimmedPart.slice(idx + 1).trim();
if (!key) {
alert('Invalid tag format. Use key=value, and key cannot be empty.');
return null;
}
if (key.length > maxKeyLength) {
alert(`Tag key length must be <= ${maxKeyLength}`);
return null;
}
if (value.length > maxValueLength) {
alert(`Tag value length must be <= ${maxValueLength}`);
return null;
}
tags[key] = value;
if (Object.keys(tags).length > maxTags) {
alert(`Too many tags. Max ${maxTags} tags allowed.`);
return null;
}
}
return tags;
}
async function openS3TablesTags(resourceArn) {
if (!resourceArn) return;
document.getElementById('s3tablesTagsResourceArn').value = resourceArn;
document.getElementById('s3tablesTagsInput').value = '';
document.getElementById('s3tablesTagsDeleteInput').value = '';
document.getElementById('s3tablesTagsList').textContent = 'Loading...';
s3tablesTagsModal.show();
try {
const response = await fetch(s3tBasePath(`/api/s3tables/tags?arn=${encodeURIComponent(resourceArn)}`));
const data = await response.json();
if (response.ok) {
document.getElementById('s3tablesTagsList').textContent = JSON.stringify(data.tags || {}, null, 2);
} else {
document.getElementById('s3tablesTagsList').textContent = data.error || 'Failed to load tags';
}
} catch (error) {
document.getElementById('s3tablesTagsList').textContent = error.message;
}
}
async function updateS3TablesTags(resourceArn, tags) {
try {
const response = await fetch(s3tBasePath('/api/s3tables/tags'), {
method: 'PUT',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ resource_arn: resourceArn, tags: tags })
});
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to update tags');
return;
}
alert('Tags updated');
openS3TablesTags(resourceArn);
} catch (error) {
alert('Failed to update tags: ' + error.message);
}
}
async function deleteS3TablesTags() {
const resourceArn = document.getElementById('s3tablesTagsResourceArn').value;
const keysInput = document.getElementById('s3tablesTagsDeleteInput').value.trim();
if (!resourceArn) return;
const tagKeys = keysInput.split(',').map(k => k.trim()).filter(k => k);
if (tagKeys.length === 0) {
alert('Provide tag keys to remove');
return;
}
try {
const response = await fetch(s3tBasePath('/api/s3tables/tags'), {
method: 'DELETE',
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ resource_arn: resourceArn, tag_keys: tagKeys })
});
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Failed to remove tags');
return;
}
alert('Tags removed');
openS3TablesTags(resourceArn);
} catch (error) {
alert('Failed to remove tags: ' + error.message);
}
}