mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-27 23:40:41 +01:00
* admin: manage S3 bucket policies from the admin UI
Bucket policies were only manageable through the S3 PutBucketPolicy API;
the admin UI had no equivalent to the quota/owner/lifecycle editors it
already offers. Add GET/PUT/DELETE for a bucket's policy, sharing the
exact validation the S3 gateway uses.
- Extract validateBucketPolicy/validateResourceForBucket out of
s3api_bucket_policy_handlers.go into policy_engine.ValidateBucketPolicy /
ResourceMatchesBucket so both the S3 API and the admin UI enforce
identical rules.
- weed/admin/dash/bucket_policy.go: Get/Set/DeleteBucketPolicy, writing
through ObjectTransaction + PATCH_EXTENDED (the lifecycle pattern) so a
concurrent owner/quota/lifecycle change on the same bucket entry isn't
clobbered. Propagation to every S3 gateway is automatic via the existing
filer metadata log subscription. The S3 gateway's IAM policy mirror is
deliberately not replicated here (its delete path is already an
unimplemented TODO on the S3 side).
- New GET/PUT/DELETE /api/s3/buckets/{bucket}/policy routes, CSRF-guarded
on writes.
- Bucket list and details modal now show a statement-count badge, read
from the entry already fetched (no extra RPC).
- UI: a JSON-textarea policy editor modal, matching the lifecycle modal's
structure.
* admin: reuse the visual policy editor for bucket policies
Extract the structured policy editor (add/remove statement, action/
resource/principal rows with autocomplete, JSON tab kept in sync) out of
policies.templ's inline script into a shared
weed/admin/static/js/policy_editor.js, and wire the bucket policy modal
in s3_buckets.templ up to it instead of a bare JSON textarea.
- registerPolicyEditor(which, config) replaces the hardcoded create/edit
id derivation with a per-instance config (textarea/tab/body ids,
datalist ids, requirePrincipal, bucket). The IAM policies page keeps its
exact pre-extraction ids via two registerPolicyEditor calls, so its
markup is unchanged.
- New policy_datalists.templ exposes the three shared <datalist>s
(actions/resources/principals) as @PolicyDatalists(), now rendered by
both policies.templ and s3_buckets.templ.
- requirePrincipal seeds new bucket-policy statements with Principal: "*"
and adds a client-side check before save (the server, via
policy_engine.ValidateBucketPolicy, remains the actual authority); the
bucket config pins the Resource autocomplete to the open bucket instead
of fetching every bucket in the cluster.
- layout.templ loads policy_editor.js globally, after admin.js/
modal-alerts.js (basePath/escapeHtml/showAlert) which it depends on.
3a (the extraction) is a byte-preserving move verified against the
unchanged policies.templ behavior before layering 3b's parameterization
and the bucket-policy wiring on top.
* admin: migrate S3 Tables bucket/table policy editors to the shared editor
Third consumer of the shared visual policy editor: the S3 Tables bucket
and table policy modals (a bare JSON textarea each) now get the same
structured Editor/JSON tabs as the bucket policy and IAM policy pages,
via registerPolicyEditor('s3tablesBucketPolicy'/'s3tablesTablePolicy',
{ textareaId: ... }). Storage and validation are untouched - S3 Tables
policies still go through their own s3tables.PolicyDocument type and the
s3tables.policy extended attribute, unrelated to policy_engine and
s3-bucket-policy; only the editor UI is shared.
Fix a real bug surfaced by adding this second load path: the bucket
policy modal (and the naive first draft of this s3tables port) called
commitPolicyTextareaToEditor() right after a GET and then force-switched
to the Editor tab. commitPolicyTextareaToEditor() is designed to leave
the current tab in place and the editor state untouched when a document
fails to parse (so an in-progress edit survives a bad tab switch), so
forcing the Editor tab afterwards could show empty/stale editor state
that a careless Save would then serialize over a perfectly valid but
structurally-unusual stored policy. Add
loadPolicyTextareaIntoEditor(which) to policy_editor.js, which has no
"current tab" to defer to and instead falls back to the JSON tab with an
alert on a document the structured editor can't represent - the same
safety editPolicy already had in policies.templ - and use it at all three
"populate the editor right after a GET" call sites (bucket policy,
S3 Tables bucket policy, S3 Tables table policy).
* admin: show policy statement count on the S3 Tables buckets page
Mirrors the "Policy" column already added to the classic S3 buckets
list: a clickable badge with the statement count when the table bucket
has a resource policy, "Not configured" otherwise. S3 Tables policies
are a separate mechanism (s3tables.PolicyDocument under the
s3tables.policy extended attribute) from the S3 bucket policy work
elsewhere in this branch (policy_engine.PolicyDocument /
s3-bucket-policy), so this is a parallel implementation of the same
pattern rather than shared code.
- S3TablesBucketSummary gains PolicyStatementCount, populated in
GetS3TablesBucketsData from entry.Entry.Extended[s3tables.ExtendedKeyPolicy]
via the new extractS3TablesPolicyStatementCountFromEntry - no extra RPC,
the entry is already fetched for ExtendedKeyMetadata.
- The badge reuses the existing .s3tables-bucket-policy-btn class, so it
opens the same policy modal as the row's action button with no JS
changes.
* admin: don't let a failed policy GET open the door to an empty overwrite
loadS3TablesBucketPolicy/loadS3TablesTablePolicy cleared the textarea,
then unconditionally called loadPolicyTextareaIntoEditor() regardless of
whether the GET actually succeeded - including when fetch() rejected or
the response was not ok, silently logged to console only. That leaves
the structured editor holding a legitimate-looking empty policy
({version, statements: []}), with the Editor tab active by default.
If Save is then clicked, commitPolicyActiveTab() serializes that empty
state into the textarea as `{"Version":"2012-10-17","Statement":[]}` -
a non-empty string - before the "Policy JSON is required" guard ever
sees it, so the guard passes and the transient load failure gets
written over whatever policy was actually stored.
Add s3tablesBucketPolicyLoaded/s3tablesTablePolicyLoaded, set true only
once a GET has actually completed (ok, including a genuinely empty
policy) and false on any failure path (fetch rejection or a non-ok
response, which previously fell through silently). Both submit handlers
now check the flag before touching the editor at all, and a failed load
surfaces via alert() instead of only a console.error - the user
previously had no visible indication the load had failed.
Verified with a jsdom simulation driving the real rendered page against
a stubbed fetch: a failed GET followed by Save now sends no PUT at all
(previously it sent Statement: []); a successful GET followed by Save
still PUTs the loaded policy unchanged.
* admin: address code review findings on the policy editor
1. policy_editor.js: policyEditors is only pre-populated for 'create'/
'edit'; every other `which` (bucket, s3tablesBucket, s3tablesTable)
stays undefined until its first successful async load. Nothing in
this file enforces that a page hide its Editor/JSON tabs and
Add-statement button until that load completes - the S3 Tables policy
modals don't - so a click in that window (e.g. Add statement, or
switching to the JSON tab) threw "Cannot read properties of undefined
(reading 'unparsed')". Add policyEditorState(which), which lazily
initializes a default state, and route addPolicyStatement, the
jsonTabBtn 'show.bs.tab' handler, commitPolicyActiveTab, and
renderPolicyEditor through it. Verified with a jsdom simulation
against a never-resolving fetch: the exact click threw on the
pre-fix code and no longer does.
2. s3_buckets.templ: the bucket-policy Save handler checked the
textarea for emptiness before calling commitPolicyActiveTab(), which
is what actually serializes the structured Editor tab's fields into
that textarea. A policy entered entirely through the Editor tab (the
primary path - never touching the JSON tab) left the textarea at
whatever it was at load time, so creating a new policy this way hit
"Enter a policy document" and Save silently did nothing. Move the
commit before the emptiness check, preserving the existing alert and
early-return. Verified with a jsdom simulation: Add-statement then
Save (no tab switch) now PUTs the entered statement; before the fix
the same sequence never reached fetch().
3. s3tables_buckets.templ / s3tables_tables.templ: the policy Editor/
JSON nav-tabs were missing the ARIA roles Bootstrap's own tab pattern
expects (role="tab"/"tabpanel", aria-selected, aria-controls,
aria-labelledby) - screen readers had no way to tell these were tabs
or which pane went with which button. Added the standard Bootstrap 5
tab markup to both.
* admin: guard policy load/save flows against overlapping requests
1. s3tables.js: loadS3TablesBucketPolicy/loadS3TablesTablePolicy had no
protection against overlapping loads. Opening one bucket's (or
table's) policy dialog and then another's before the first GET
resolved let the late response write its document into the shared
textarea and mark the dialog "loaded" while it was now targeting the
second resource - a subsequent Save would then push the first
resource's policy onto the second. Add a per-load monotonic sequence
number (s3tablesBucketPolicyRequestSeq / s3tablesTablePolicyRequestSeq,
the same pattern already used for the classic bucket-policy load in
s3_buckets.templ); a response is only applied - textarea, loaded flag,
editor state - if its captured sequence still matches the latest one
issued.
Verified with a jsdom simulation: bucket A's policy load (artificially
slow) followed immediately by bucket B's (fast) previously left A's
policy in the textarea once A's late response landed; it now correctly
keeps B's.
2. s3_buckets.templ: the bucket-policy Save button lives outside the
(initially hidden) editor wrapper, so it stays clickable while a load
is still in flight - the existing policyRequestSeq guard only protects
the *load* from a stale response, not Save from firing before any
load for the current bucket has completed. Add bucketPolicyLoaded,
reset before each GET and set only once the matching response lands,
and check it at the top of the Save handler.
Verified with a jsdom simulation: clicking Save immediately after
opening the dialog, before a (deliberately never-resolving) GET
settles, now sends no PUT; a normal load-then-save sequence still
PUTs the loaded policy unchanged.
* admin: address further code review findings on the policy editor
1. s3tables.js: loadS3TablesBucketPolicy/loadS3TablesTablePolicy only
reset the JSON textarea when a new load starts; the structured editor
kept showing the previously loaded resource's statements (Editor tab
is the default active one) until the new fetch resolved. Call
loadPolicyTextareaIntoEditor() against the now-cleared textarea
immediately, so switching resources visibly resets the editor right
away instead of only once its own load completes. Verified with jsdom:
opening bucket A (loads fully) then bucket B (GET never resolves) no
longer leaves A's statements visible in B's editor.
2. s3tables.js: deleteS3TablesBucketPolicy/deleteS3TablesTablePolicy had
no loaded-state check, so a failed GET (which already blocks Save)
left Delete fully able to remove the resource's stored policy sight
unseen. Add the same s3tablesBucketPolicyLoaded/s3tablesTablePolicyLoaded
guard Save already uses. Verified with jsdom: delete after a failed
load now sends no DELETE; delete after a successful load is unaffected.
3. s3_buckets.templ: the bucket-policy Editor/JSON nav-tabs were missing
the same ARIA roles already added to the S3 Tables policy tabs in an
earlier round (role="tab"/"tabpanel", aria-selected, aria-controls,
aria-labelledby) - this instance was out of scope for that review
comment but is the same gap. Bootstrap's own tab.js already manages
aria-selected on tab switch once the attribute exists, so no extra JS
was needed.
4. s3_buckets.templ: neither the bucket-policy Save nor Delete handler
guarded against a double-click, or against firing while the other was
still in flight - two overlapping PUT/DELETE requests for the same
bucket could land in either order. Add a shared
bucketPolicyMutationInFlight flag: set (and both buttons disabled)
before each fetch, cleared (and buttons re-enabled) on failure so the
user can retry, left set through the existing success hide-and-reload
path, and also reset when a new bucket's dialog opens so an abandoned
in-flight request from a closed dialog can't leave the buttons stuck
disabled. Verified with jsdom: double-clicking Save now sends exactly
one PUT, and a Delete click while that PUT is still pending sends no
DELETE.
* admin: scope bucket-policy mutation completions to the bucket that started them
1. The previous round's fix reset bucketPolicyMutationInFlight whenever a
new bucket's policy dialog opened, to avoid leaving Save/Delete stuck
disabled if the modal was closed mid-request. That traded one bug for
a worse one: if bucket A's PUT/DELETE was still in flight when the
user opened bucket B's dialog, the reset let B's Save/Delete fire
immediately, and A's completion handler - unaware anything had
changed - would still hide the (now B's) modal and reload the page
out from under whatever the user was doing with B, on success, or
alert a message with no bucket context, on failure.
Stop resetting on reopen, so a pending mutation for a previous bucket
keeps this bucket's Save/Delete blocked until it settles (matches the
"preventing overlapping mutations" the review comment describes).
Instead, capture policyEditorBucket as targetBucket right before each
fetch and compare it against policyEditorBucket again in the
completion handler: the in-flight flag is always released so the
buttons never get stuck, but the modal-hide/reload/alert only fire if
this bucket is still the one showing; a stale completion for an
abandoned bucket just logs to the console instead.
Verified with a jsdom simulation: opening bucket B while bucket A's
Save is still pending leaves B's Save button disabled and a click on
it a no-op; once A's PUT resolves, B's button re-enables but no
modal.hide()/reload() fires (previously both fired unconditionally).
2. bucketPolicyDeleteBtn had no bucketPolicyLoaded check, unlike Save -
a failed GET blocked Save but left Delete free to remove a policy the
client never actually saw (the same gap already fixed for the S3
Tables policy modals in an earlier round). Added the same guard,
ahead of the confirm() dialog. Verified with jsdom: Delete after a
failed load now sends no DELETE request.
* admin: fix spelling mistake
1047 lines
43 KiB
JavaScript
1047 lines
43 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;
|
|
|
|
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'));
|
|
|
|
// Shared visual policy editor (weed/admin/static/js/policy_editor.js),
|
|
// reused here from the bucket policy admin page. Table bucket policies
|
|
// aren't validated against policy_engine.PolicyDocument server-side
|
|
// (see s3tables/permissions.go's separate PolicyDocument type), so no
|
|
// requirePrincipal/bucket config is set - the editor just gives a
|
|
// structured view over the same JSON the JSON tab holds.
|
|
registerPolicyEditor('s3tablesBucket', { textareaId: 's3tablesBucketPolicyText' });
|
|
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;
|
|
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) {
|
|
policyForm.addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
if (!s3tablesBucketPolicyLoaded) {
|
|
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
|
|
return;
|
|
}
|
|
if (!commitPolicyActiveTab('s3tablesBucket')) return;
|
|
const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value;
|
|
const policy = document.getElementById('s3tablesBucketPolicyText').value.trim();
|
|
if (!policy) {
|
|
alert('Policy JSON is required');
|
|
return;
|
|
}
|
|
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();
|
|
if (!response.ok) {
|
|
alert(data.error || 'Failed to update policy');
|
|
return;
|
|
}
|
|
alert('Policy updated');
|
|
s3tablesBucketPolicyModal.hide();
|
|
} catch (error) {
|
|
alert('Failed to update policy: ' + error.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
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'));
|
|
|
|
registerPolicyEditor('s3tablesTable', { textareaId: 's3tablesTablePolicyText' });
|
|
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 || '';
|
|
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) {
|
|
policyForm.addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
if (!s3tablesTablePolicyLoaded) {
|
|
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
|
|
return;
|
|
}
|
|
if (!commitPolicyActiveTab('s3tablesTable')) return;
|
|
const policy = document.getElementById('s3tablesTablePolicyText').value.trim();
|
|
if (!policy) {
|
|
alert('Policy JSON is required');
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(s3tBasePath('/api/s3tables/table-policy'), {
|
|
method: 'PUT',
|
|
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ bucket_arn: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value, policy: policy })
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
alert(data.error || 'Failed to update policy');
|
|
return;
|
|
}
|
|
alert('Policy updated');
|
|
s3tablesTablePolicyModal.hide();
|
|
} catch (error) {
|
|
alert('Failed to update policy: ' + error.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 deleteS3TablesBucketPolicy() {
|
|
const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value;
|
|
if (!bucketArn) return;
|
|
if (!s3tablesBucketPolicyLoaded) {
|
|
alert('The current policy has not finished loading. Close and reopen this dialog before deleting.');
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE', headers: s3tWriteHeaders() });
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
alert(data.error || 'Failed to delete policy');
|
|
return;
|
|
}
|
|
alert('Policy deleted');
|
|
document.getElementById('s3tablesBucketPolicyText').value = '';
|
|
commitPolicyTextareaToEditor('s3tablesBucket');
|
|
} catch (error) {
|
|
alert('Failed to delete policy: ' + 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 (!s3tablesTablePolicyLoaded) {
|
|
alert('The current policy has not finished loading. Close and reopen this dialog before deleting.');
|
|
return;
|
|
}
|
|
const dataContainer = document.getElementById('s3tables-tables-content');
|
|
const dataBucketArn = dataContainer.dataset.bucketArn || '';
|
|
const dataNamespace = dataContainer.dataset.namespace || '';
|
|
const query = new URLSearchParams({ bucket: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value });
|
|
try {
|
|
const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
alert(data.error || 'Failed to delete policy');
|
|
return;
|
|
}
|
|
alert('Policy deleted');
|
|
document.getElementById('s3tablesTablePolicyText').value = '';
|
|
commitPolicyTextareaToEditor('s3tablesTable');
|
|
} catch (error) {
|
|
alert('Failed to delete 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);
|
|
}
|
|
}
|