Implement dynamic UI loading

This commit is contained in:
2026-01-11 22:36:04 +08:00
parent c5d4b2f1cd
commit 0d1fe05fd0
11 changed files with 2122 additions and 567 deletions

View File

@@ -393,6 +393,8 @@
{% endwith %}
})();
</script>
<script src="{{ url_for('static', filename='js/ui-core.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -473,15 +473,13 @@
</svg>
Save Policy
</button>
{% if bucket_policy %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deletePolicyModal">
<button type="button" class="btn btn-outline-danger" id="deletePolicyBtn" data-bs-toggle="modal" data-bs-target="#deletePolicyModal"{% if not bucket_policy %} style="display: none;"{% endif %}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Delete Policy
</button>
{% endif %}
</div>
</form>
{% else %}
@@ -636,7 +634,7 @@
Suspend Versioning
</button>
{% else %}
<form method="post" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}">
<form method="post" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}" id="enableVersioningForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="state" value="enable" />
<button class="btn btn-success" type="submit">
@@ -931,7 +929,8 @@
{% if can_manage_quota %}
<form method="post" action="{{ url_for('ui.update_bucket_quota', bucket_name=bucket_name) }}" id="quotaForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="action" value="set" id="quotaAction" />
<div class="mb-3">
<label for="max_mb" class="form-label fw-medium">Maximum Storage Size</label>
<div class="input-group">
@@ -958,14 +957,12 @@
</svg>
Save Quota Settings
</button>
{% if has_quota %}
<button type="submit" class="btn btn-outline-danger" id="removeQuotaBtn" name="action" value="remove">
<button type="button" class="btn btn-outline-danger" id="removeQuotaBtn"{% if not has_quota %} style="display: none;"{% endif %}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
Remove Quota
</button>
{% endif %}
</div>
</form>
{% else %}
@@ -1856,7 +1853,7 @@
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="post" action="{{ url_for('ui.update_bucket_policy', bucket_name=bucket_name) }}" class="d-inline">
<form method="post" action="{{ url_for('ui.update_bucket_policy', bucket_name=bucket_name) }}" class="d-inline" id="deletePolicyForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="mode" value="delete" />
<button type="submit" class="btn btn-danger">Delete Policy</button>
@@ -2037,7 +2034,7 @@
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{{ url_for('ui.delete_bucket', bucket_name=bucket_name) }}">
<form method="post" action="{{ url_for('ui.delete_bucket', bucket_name=bucket_name) }}" id="deleteBucketForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body">
<div class="alert alert-danger d-flex align-items-center mb-3" role="alert">
@@ -2267,7 +2264,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}" class="d-inline">
<form method="POST" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}" class="d-inline" id="suspendVersioningForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="state" value="suspend" />
<button type="submit" class="btn btn-warning">
@@ -2552,4 +2549,12 @@
<script src="{{ url_for('static', filename='js/bucket-detail-upload.js') }}"></script>
<script src="{{ url_for('static', filename='js/bucket-detail-operations.js') }}"></script>
<script src="{{ url_for('static', filename='js/bucket-detail-main.js') }}"></script>
<script>
window.BucketDetailConfig = {
endpoints: {
versioning: "{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}",
bucketsOverview: "{{ url_for('ui.buckets_overview') }}"
}
};
</script>
{% endblock %}

View File

@@ -104,7 +104,7 @@
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{{ url_for('ui.create_bucket') }}">
<form method="post" action="{{ url_for('ui.create_bucket') }}" id="createBucketForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body pt-0">
<label class="form-label fw-medium">Bucket name</label>
@@ -205,6 +205,25 @@
});
row.style.cursor = 'pointer';
});
var createForm = document.getElementById('createBucketForm');
if (createForm) {
createForm.addEventListener('submit', function(e) {
e.preventDefault();
window.UICore.submitFormAjax(createForm, {
successMessage: 'Bucket created',
onSuccess: function(data) {
var modal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));
if (modal) modal.hide();
if (data.bucket_name) {
window.location.href = '{{ url_for("ui.bucket_detail", bucket_name="__BUCKET__") }}'.replace('__BUCKET__', data.bucket_name);
} else {
location.reload();
}
}
});
});
}
})();
</script>
{% endblock %}

View File

@@ -57,7 +57,7 @@
<label for="secret_key" class="form-label fw-medium">Secret Key</label>
<div class="input-group">
<input type="password" class="form-control font-monospace" id="secret_key" name="secret_key" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('secret_key')" title="Toggle visibility">
<button class="btn btn-outline-secondary" type="button" onclick="ConnectionsManagement.togglePassword('secret_key')" title="Toggle visibility">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
@@ -220,7 +220,7 @@
<label for="edit_secret_key" class="form-label fw-medium">Secret Key</label>
<div class="input-group">
<input type="password" class="form-control font-monospace" id="edit_secret_key" name="secret_key" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('edit_secret_key')">
<button class="btn btn-outline-secondary" type="button" onclick="ConnectionsManagement.togglePassword('edit_secret_key')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
@@ -289,153 +289,16 @@
</div>
</div>
<script src="{{ url_for('static', filename='js/connections-management.js') }}"></script>
<script>
function togglePassword(id) {
const input = document.getElementById(id);
if (input.type === "password") {
input.type = "text";
} else {
input.type = "password";
}
ConnectionsManagement.init({
csrfToken: "{{ csrf_token() }}",
endpoints: {
test: "{{ url_for('ui.test_connection') }}",
updateTemplate: "{{ url_for('ui.update_connection', connection_id='CONNECTION_ID') }}",
deleteTemplate: "{{ url_for('ui.delete_connection', connection_id='CONNECTION_ID') }}",
healthTemplate: "/ui/connections/CONNECTION_ID/health"
}
async function testConnection(formId, resultId) {
const form = document.getElementById(formId);
const resultDiv = document.getElementById(resultId);
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
resultDiv.innerHTML = '<div class="text-info"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Testing connection...</div>';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
const response = await fetch("{{ url_for('ui.test_connection') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token() }}"
},
body: JSON.stringify(data),
signal: controller.signal
});
clearTimeout(timeoutId);
const result = await response.json();
if (response.ok) {
resultDiv.innerHTML = `<div class="text-success">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
${result.message}
</div>`;
} else {
resultDiv.innerHTML = `<div class="text-danger">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
${result.message}
</div>`;
}
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
resultDiv.innerHTML = `<div class="text-danger">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
Connection test timed out - endpoint may be unreachable
</div>`;
} else {
resultDiv.innerHTML = `<div class="text-danger">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
Connection failed: Network error
</div>`;
}
}
}
document.getElementById('testConnectionBtn').addEventListener('click', () => {
testConnection('createConnectionForm', 'testResult');
});
document.getElementById('editTestConnectionBtn').addEventListener('click', () => {
testConnection('editConnectionForm', 'editTestResult');
});
const editModal = document.getElementById('editConnectionModal');
editModal.addEventListener('show.bs.modal', event => {
const button = event.relatedTarget;
const id = button.getAttribute('data-id');
document.getElementById('edit_name').value = button.getAttribute('data-name');
document.getElementById('edit_endpoint_url').value = button.getAttribute('data-endpoint');
document.getElementById('edit_region').value = button.getAttribute('data-region');
document.getElementById('edit_access_key').value = button.getAttribute('data-access');
document.getElementById('edit_secret_key').value = button.getAttribute('data-secret');
document.getElementById('editTestResult').innerHTML = '';
const form = document.getElementById('editConnectionForm');
form.action = "{{ url_for('ui.update_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
});
const deleteModal = document.getElementById('deleteConnectionModal');
deleteModal.addEventListener('show.bs.modal', event => {
const button = event.relatedTarget;
const id = button.getAttribute('data-id');
const name = button.getAttribute('data-name');
document.getElementById('deleteConnectionName').textContent = name;
const form = document.getElementById('deleteConnectionForm');
form.action = "{{ url_for('ui.delete_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
});
async function checkConnectionHealth(connectionId, statusEl) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
const response = await fetch(`/ui/connections/${connectionId}/health`, {
signal: controller.signal
});
clearTimeout(timeoutId);
const data = await response.json();
if (data.healthy) {
statusEl.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>`;
statusEl.setAttribute('data-status', 'healthy');
statusEl.setAttribute('title', 'Connected');
} else {
statusEl.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>`;
statusEl.setAttribute('data-status', 'unhealthy');
statusEl.setAttribute('title', data.error || 'Unreachable');
}
} catch (error) {
statusEl.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>`;
statusEl.setAttribute('data-status', 'unknown');
statusEl.setAttribute('title', 'Could not check status');
}
}
const connectionRows = document.querySelectorAll('tr[data-connection-id]');
connectionRows.forEach((row, index) => {
const connectionId = row.getAttribute('data-connection-id');
const statusEl = row.querySelector('.connection-status');
if (statusEl) {
setTimeout(() => checkConnectionHealth(connectionId, statusEl), index * 200);
}
});
});
</script>
{% endblock %}

View File

@@ -454,339 +454,20 @@
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/iam-management.js') }}"></script>
<script>
(function () {
function setupJsonAutoIndent(textarea) {
if (!textarea) return;
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
const value = this.value;
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const currentLine = value.substring(lineStart, start);
const indentMatch = currentLine.match(/^(\s*)/);
let indent = indentMatch ? indentMatch[1] : '';
const trimmedLine = currentLine.trim();
const lastChar = trimmedLine.slice(-1);
const charBeforeCursor = value.substring(start - 1, start).trim();
let newIndent = indent;
let insertAfter = '';
if (lastChar === '{' || lastChar === '[') {
newIndent = indent + ' ';
const charAfterCursor = value.substring(start, start + 1).trim();
if ((lastChar === '{' && charAfterCursor === '}') ||
(lastChar === '[' && charAfterCursor === ']')) {
insertAfter = '\n' + indent;
}
} else if (lastChar === ',' || lastChar === ':') {
newIndent = indent;
}
const insertion = '\n' + newIndent + insertAfter;
const newValue = value.substring(0, start) + insertion + value.substring(end);
this.value = newValue;
const newCursorPos = start + 1 + newIndent.length;
this.selectionStart = this.selectionEnd = newCursorPos;
this.dispatchEvent(new Event('input', { bubbles: true }));
}
if (e.key === 'Tab') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
if (e.shiftKey) {
const lineStart = this.value.lastIndexOf('\n', start - 1) + 1;
const lineContent = this.value.substring(lineStart, start);
if (lineContent.startsWith(' ')) {
this.value = this.value.substring(0, lineStart) +
this.value.substring(lineStart + 2);
this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2);
}
} else {
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}
});
IAMManagement.init({
users: JSON.parse(document.getElementById('iamUsersJson').textContent || '[]'),
currentUserKey: {{ principal.access_key | tojson }},
iamLocked: {{ iam_locked | tojson }},
csrfToken: "{{ csrf_token() }}",
endpoints: {
createUser: "{{ url_for('ui.create_iam_user') }}",
updateUser: "{{ url_for('ui.update_iam_user', access_key='ACCESS_KEY') }}",
deleteUser: "{{ url_for('ui.delete_iam_user', access_key='ACCESS_KEY') }}",
updatePolicies: "{{ url_for('ui.update_iam_policies', access_key='ACCESS_KEY') }}",
rotateSecret: "{{ url_for('ui.rotate_iam_secret', access_key='ACCESS_KEY') }}"
}
setupJsonAutoIndent(document.getElementById('policyEditorDocument'));
setupJsonAutoIndent(document.getElementById('createUserPolicies'));
const currentUserKey = {{ principal.access_key | tojson }};
const configCopyButtons = document.querySelectorAll('.config-copy');
configCopyButtons.forEach((button) => {
button.addEventListener('click', async () => {
const targetId = button.dataset.copyTarget;
const target = document.getElementById(targetId);
if (!target) return;
const text = target.innerText;
try {
await navigator.clipboard.writeText(text);
button.textContent = 'Copied!';
setTimeout(() => {
button.textContent = 'Copy JSON';
}, 1500);
} catch (err) {
console.error('Unable to copy IAM config', err);
}
});
});
const secretCopyButton = document.querySelector('[data-secret-copy]');
if (secretCopyButton) {
secretCopyButton.addEventListener('click', async () => {
const secretInput = document.getElementById('disclosedSecretValue');
if (!secretInput) return;
try {
await navigator.clipboard.writeText(secretInput.value);
secretCopyButton.textContent = 'Copied!';
setTimeout(() => {
secretCopyButton.textContent = 'Copy';
}, 1500);
} catch (err) {
console.error('Unable to copy IAM secret', err);
}
});
}
const iamUsersData = document.getElementById('iamUsersJson');
const users = iamUsersData ? JSON.parse(iamUsersData.textContent || '[]') : [];
const policyModalEl = document.getElementById('policyEditorModal');
const policyModal = new bootstrap.Modal(policyModalEl);
const userLabelEl = document.getElementById('policyEditorUserLabel');
const userInputEl = document.getElementById('policyEditorUser');
const textareaEl = document.getElementById('policyEditorDocument');
const formEl = document.getElementById('policyEditorForm');
const templateButtons = document.querySelectorAll('[data-policy-template]');
const iamLocked = {{ iam_locked | tojson }};
if (iamLocked) return;
const userPolicies = (accessKey) => {
const target = users.find((user) => user.access_key === accessKey);
return target ? JSON.stringify(target.policies, null, 2) : '';
};
const applyTemplate = (name) => {
const templates = {
full: [
{
bucket: '*',
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'iam:list_users', 'iam:*'],
},
],
readonly: [
{
bucket: '*',
actions: ['list', 'read'],
},
],
writer: [
{
bucket: '*',
actions: ['list', 'read', 'write'],
},
],
};
if (templates[name]) {
textareaEl.value = JSON.stringify(templates[name], null, 2);
}
};
templateButtons.forEach((button) => {
button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate));
});
const createUserPoliciesEl = document.getElementById('createUserPolicies');
const createTemplateButtons = document.querySelectorAll('[data-create-policy-template]');
const applyCreateTemplate = (name) => {
const templates = {
full: [
{
bucket: '*',
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'iam:list_users', 'iam:*'],
},
],
readonly: [
{
bucket: '*',
actions: ['list', 'read'],
},
],
writer: [
{
bucket: '*',
actions: ['list', 'read', 'write'],
},
],
};
if (templates[name] && createUserPoliciesEl) {
createUserPoliciesEl.value = JSON.stringify(templates[name], null, 2);
}
};
createTemplateButtons.forEach((button) => {
button.addEventListener('click', () => applyCreateTemplate(button.dataset.createPolicyTemplate));
});
formEl?.addEventListener('submit', (event) => {
const key = userInputEl.value;
if (!key) {
event.preventDefault();
return;
}
const template = formEl.dataset.actionTemplate;
formEl.action = template.replace('ACCESS_KEY_PLACEHOLDER', key);
});
document.querySelectorAll('[data-policy-editor]').forEach((button) => {
button.addEventListener('click', () => {
const key = button.getAttribute('data-access-key');
if (!key) return;
userLabelEl.textContent = key;
userInputEl.value = key;
textareaEl.value = userPolicies(key);
policyModal.show();
});
});
const editUserModal = new bootstrap.Modal(document.getElementById('editUserModal'));
const editUserForm = document.getElementById('editUserForm');
const editUserDisplayName = document.getElementById('editUserDisplayName');
document.querySelectorAll('[data-edit-user]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.editUser;
const name = btn.dataset.displayName;
editUserDisplayName.value = name;
editUserForm.action = "{{ url_for('ui.update_iam_user', access_key='ACCESS_KEY') }}".replace('ACCESS_KEY', key);
editUserModal.show();
});
});
const deleteUserModal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
const deleteUserForm = document.getElementById('deleteUserForm');
const deleteUserLabel = document.getElementById('deleteUserLabel');
const deleteSelfWarning = document.getElementById('deleteSelfWarning');
document.querySelectorAll('[data-delete-user]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.deleteUser;
deleteUserLabel.textContent = key;
deleteUserForm.action = "{{ url_for('ui.delete_iam_user', access_key='ACCESS_KEY') }}".replace('ACCESS_KEY', key);
if (key === currentUserKey) {
deleteSelfWarning.classList.remove('d-none');
} else {
deleteSelfWarning.classList.add('d-none');
}
deleteUserModal.show();
});
});
const rotateSecretModal = new bootstrap.Modal(document.getElementById('rotateSecretModal'));
const rotateUserLabel = document.getElementById('rotateUserLabel');
const confirmRotateBtn = document.getElementById('confirmRotateBtn');
const rotateCancelBtn = document.getElementById('rotateCancelBtn');
const rotateDoneBtn = document.getElementById('rotateDoneBtn');
const rotateSecretConfirm = document.getElementById('rotateSecretConfirm');
const rotateSecretResult = document.getElementById('rotateSecretResult');
const newSecretKeyInput = document.getElementById('newSecretKey');
const copyNewSecretBtn = document.getElementById('copyNewSecret');
let currentRotateKey = null;
document.querySelectorAll('[data-rotate-user]').forEach(btn => {
btn.addEventListener('click', () => {
currentRotateKey = btn.dataset.rotateUser;
rotateUserLabel.textContent = currentRotateKey;
rotateSecretConfirm.classList.remove('d-none');
rotateSecretResult.classList.add('d-none');
confirmRotateBtn.classList.remove('d-none');
rotateCancelBtn.classList.remove('d-none');
rotateDoneBtn.classList.add('d-none');
rotateSecretModal.show();
});
});
confirmRotateBtn.addEventListener('click', async () => {
if (!currentRotateKey) return;
confirmRotateBtn.disabled = true;
confirmRotateBtn.textContent = "Rotating...";
try {
const url = "{{ url_for('ui.rotate_iam_secret', access_key='ACCESS_KEY') }}".replace('ACCESS_KEY', currentRotateKey);
const response = await fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-CSRFToken': "{{ csrf_token() }}"
}
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to rotate secret');
}
const data = await response.json();
newSecretKeyInput.value = data.secret_key;
rotateSecretConfirm.classList.add('d-none');
rotateSecretResult.classList.remove('d-none');
confirmRotateBtn.classList.add('d-none');
rotateCancelBtn.classList.add('d-none');
rotateDoneBtn.classList.remove('d-none');
} catch (err) {
if (window.showToast) {
window.showToast(err.message, 'Error', 'danger');
}
rotateSecretModal.hide();
} finally {
confirmRotateBtn.disabled = false;
confirmRotateBtn.textContent = "Rotate Key";
}
});
copyNewSecretBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(newSecretKeyInput.value);
copyNewSecretBtn.textContent = 'Copied!';
setTimeout(() => copyNewSecretBtn.textContent = 'Copy', 1500);
} catch (err) {
console.error('Failed to copy', err);
}
});
rotateDoneBtn.addEventListener('click', () => {
window.location.reload();
});
})();
});
</script>
{% endblock %}

View File

@@ -6,11 +6,11 @@
<p class="text-muted mb-0">Real-time server performance and storage usage</p>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="d-flex align-items-center gap-2 text-muted small">
<span class="d-flex align-items-center gap-2 text-muted small" id="metricsLiveIndicator">
<span class="live-indicator"></span>
Live
Auto-refresh: <span id="refreshCountdown">5</span>s
</span>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">
<button class="btn btn-outline-secondary btn-sm" id="refreshMetricsBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
@@ -32,15 +32,13 @@
</svg>
</div>
</div>
<h2 class="display-6 fw-bold mb-2 stat-value">{{ cpu_percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
<h2 class="display-6 fw-bold mb-2 stat-value"><span data-metric="cpu_percent">{{ cpu_percent }}</span><span class="fs-4 fw-normal text-muted">%</span></h2>
<div class="progress" style="height: 8px; border-radius: 4px;">
<div class="progress-bar {% if cpu_percent > 80 %}bg-danger{% elif cpu_percent > 50 %}bg-warning{% else %}bg-primary{% endif %}" role="progressbar" style="width: {{ cpu_percent }}%"></div>
<div class="progress-bar bg-primary" data-metric="cpu_bar" role="progressbar" style="width: {{ cpu_percent }}%"></div>
</div>
<div class="mt-2 d-flex justify-content-between">
<small class="text-muted">Current load</small>
<small class="{% if cpu_percent > 80 %}text-danger{% elif cpu_percent > 50 %}text-warning{% else %}text-success{% endif %}">
{% if cpu_percent > 80 %}High{% elif cpu_percent > 50 %}Medium{% else %}Normal{% endif %}
</small>
<small data-metric="cpu_status" class="text-success">Normal</small>
</div>
</div>
</div>
@@ -57,13 +55,13 @@
</svg>
</div>
</div>
<h2 class="display-6 fw-bold mb-2 stat-value">{{ memory.percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
<h2 class="display-6 fw-bold mb-2 stat-value"><span data-metric="memory_percent">{{ memory.percent }}</span><span class="fs-4 fw-normal text-muted">%</span></h2>
<div class="progress" style="height: 8px; border-radius: 4px;">
<div class="progress-bar bg-info" role="progressbar" style="width: {{ memory.percent }}%"></div>
<div class="progress-bar bg-info" data-metric="memory_bar" role="progressbar" style="width: {{ memory.percent }}%"></div>
</div>
<div class="mt-2 d-flex justify-content-between">
<small class="text-muted">{{ memory.used }} used</small>
<small class="text-muted">{{ memory.total }} total</small>
<small class="text-muted"><span data-metric="memory_used">{{ memory.used }}</span> used</small>
<small class="text-muted"><span data-metric="memory_total">{{ memory.total }}</span> total</small>
</div>
</div>
</div>
@@ -81,13 +79,13 @@
</svg>
</div>
</div>
<h2 class="display-6 fw-bold mb-2 stat-value">{{ disk.percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
<h2 class="display-6 fw-bold mb-2 stat-value"><span data-metric="disk_percent">{{ disk.percent }}</span><span class="fs-4 fw-normal text-muted">%</span></h2>
<div class="progress" style="height: 8px; border-radius: 4px;">
<div class="progress-bar {% if disk.percent > 90 %}bg-danger{% elif disk.percent > 75 %}bg-warning{% else %}bg-warning{% endif %}" role="progressbar" style="width: {{ disk.percent }}%"></div>
<div class="progress-bar bg-warning" data-metric="disk_bar" role="progressbar" style="width: {{ disk.percent }}%"></div>
</div>
<div class="mt-2 d-flex justify-content-between">
<small class="text-muted">{{ disk.free }} free</small>
<small class="text-muted">{{ disk.total }} total</small>
<small class="text-muted"><span data-metric="disk_free">{{ disk.free }}</span> free</small>
<small class="text-muted"><span data-metric="disk_total">{{ disk.total }}</span> total</small>
</div>
</div>
</div>
@@ -104,15 +102,15 @@
</svg>
</div>
</div>
<h2 class="display-6 fw-bold mb-2 stat-value">{{ app.storage_used }}</h2>
<h2 class="display-6 fw-bold mb-2 stat-value" data-metric="storage_used">{{ app.storage_used }}</h2>
<div class="d-flex gap-3 mt-3">
<div class="text-center flex-fill">
<div class="h5 fw-bold mb-0">{{ app.buckets }}</div>
<div class="h5 fw-bold mb-0" data-metric="buckets_count">{{ app.buckets }}</div>
<small class="text-muted">Buckets</small>
</div>
<div class="vr"></div>
<div class="text-center flex-fill">
<div class="h5 fw-bold mb-0">{{ app.objects }}</div>
<div class="h5 fw-bold mb-0" data-metric="objects_count">{{ app.objects }}</div>
<small class="text-muted">Objects</small>
</div>
</div>
@@ -270,3 +268,109 @@
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function() {
var refreshInterval = 5000;
var countdown = 5;
var countdownEl = document.getElementById('refreshCountdown');
var refreshBtn = document.getElementById('refreshMetricsBtn');
var countdownTimer = null;
var fetchTimer = null;
function updateMetrics() {
fetch('/ui/metrics/api')
.then(function(resp) { return resp.json(); })
.then(function(data) {
var el;
el = document.querySelector('[data-metric="cpu_percent"]');
if (el) el.textContent = data.cpu_percent;
el = document.querySelector('[data-metric="cpu_bar"]');
if (el) {
el.style.width = data.cpu_percent + '%';
el.className = 'progress-bar ' + (data.cpu_percent > 80 ? 'bg-danger' : data.cpu_percent > 50 ? 'bg-warning' : 'bg-primary');
}
el = document.querySelector('[data-metric="cpu_status"]');
if (el) {
el.textContent = data.cpu_percent > 80 ? 'High' : data.cpu_percent > 50 ? 'Medium' : 'Normal';
el.className = data.cpu_percent > 80 ? 'text-danger' : data.cpu_percent > 50 ? 'text-warning' : 'text-success';
}
el = document.querySelector('[data-metric="memory_percent"]');
if (el) el.textContent = data.memory.percent;
el = document.querySelector('[data-metric="memory_bar"]');
if (el) el.style.width = data.memory.percent + '%';
el = document.querySelector('[data-metric="memory_used"]');
if (el) el.textContent = data.memory.used;
el = document.querySelector('[data-metric="memory_total"]');
if (el) el.textContent = data.memory.total;
el = document.querySelector('[data-metric="disk_percent"]');
if (el) el.textContent = data.disk.percent;
el = document.querySelector('[data-metric="disk_bar"]');
if (el) {
el.style.width = data.disk.percent + '%';
el.className = 'progress-bar ' + (data.disk.percent > 90 ? 'bg-danger' : 'bg-warning');
}
el = document.querySelector('[data-metric="disk_free"]');
if (el) el.textContent = data.disk.free;
el = document.querySelector('[data-metric="disk_total"]');
if (el) el.textContent = data.disk.total;
el = document.querySelector('[data-metric="storage_used"]');
if (el) el.textContent = data.app.storage_used;
el = document.querySelector('[data-metric="buckets_count"]');
if (el) el.textContent = data.app.buckets;
el = document.querySelector('[data-metric="objects_count"]');
if (el) el.textContent = data.app.objects;
countdown = 5;
})
.catch(function(err) {
console.error('Metrics fetch error:', err);
});
}
function startCountdown() {
if (countdownTimer) clearInterval(countdownTimer);
countdown = 5;
if (countdownEl) countdownEl.textContent = countdown;
countdownTimer = setInterval(function() {
countdown--;
if (countdownEl) countdownEl.textContent = countdown;
if (countdown <= 0) {
countdown = 5;
}
}, 1000);
}
function startPolling() {
if (fetchTimer) clearInterval(fetchTimer);
fetchTimer = setInterval(function() {
if (!document.hidden) {
updateMetrics();
}
}, refreshInterval);
startCountdown();
}
if (refreshBtn) {
refreshBtn.addEventListener('click', function() {
updateMetrics();
countdown = 5;
if (countdownEl) countdownEl.textContent = countdown;
});
}
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
updateMetrics();
startPolling();
}
});
startPolling();
})();
</script>
{% endblock %}