diff --git a/app/ui.py b/app/ui.py index 70350fd..1f2ecf3 100644 --- a/app/ui.py +++ b/app/ui.py @@ -102,6 +102,12 @@ def _friendly_error_message(exc: Exception) -> str: return message +def _wants_json() -> bool: + return request.accept_mimetypes.best_match( + ["application/json", "text/html"] + ) == "application/json" + + def _policy_allows_public_read(policy: dict[str, Any]) -> bool: statements = policy.get("Statement", []) if isinstance(statements, dict): @@ -285,13 +291,19 @@ def create_bucket(): principal = _current_principal() bucket_name = request.form.get("bucket_name", "").strip() if not bucket_name: + if _wants_json(): + return jsonify({"error": "Bucket name is required"}), 400 flash("Bucket name is required", "danger") return redirect(url_for("ui.buckets_overview")) try: _authorize_ui(principal, bucket_name, "write") _storage().create_bucket(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": f"Bucket '{bucket_name}' created", "bucket_name": bucket_name}) flash(f"Bucket '{bucket_name}' created", "success") except (StorageError, FileExistsError, IamError) as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.buckets_overview")) @@ -649,8 +661,12 @@ def delete_bucket(bucket_name: str): _storage().delete_bucket(bucket_name) _bucket_policies().delete_policy(bucket_name) _replication_manager().delete_rule(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": f"Bucket '{bucket_name}' removed"}) flash(f"Bucket '{bucket_name}' removed", "success") except (StorageError, IamError) as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.buckets_overview")) @@ -664,12 +680,17 @@ def delete_object(bucket_name: str, object_key: str): _authorize_ui(principal, bucket_name, "delete", object_key=object_key) if purge_versions: _storage().purge_object(bucket_name, object_key) - flash(f"Permanently deleted '{object_key}' and all versions", "success") + message = f"Permanently deleted '{object_key}' and all versions" else: _storage().delete_object(bucket_name, object_key) _replication_manager().trigger_replication(bucket_name, object_key, action="delete") - flash(f"Deleted '{object_key}'", "success") + message = f"Deleted '{object_key}'" + if _wants_json(): + return jsonify({"success": True, "message": message}) + flash(message, "success") except (IamError, StorageError) as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name)) @@ -979,22 +1000,32 @@ def update_bucket_policy(bucket_name: str): try: _authorize_ui(principal, bucket_name, "policy") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name)) store = _bucket_policies() if action == "delete": store.delete_policy(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": "Bucket policy removed"}) flash("Bucket policy removed", "info") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions")) document = request.form.get("policy_document", "").strip() if not document: + if _wants_json(): + return jsonify({"error": "Provide a JSON policy document"}), 400 flash("Provide a JSON policy document", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions")) try: payload = json.loads(document) store.set_policy(bucket_name, payload) + if _wants_json(): + return jsonify({"success": True, "message": "Bucket policy saved"}) flash("Bucket policy saved", "success") except (json.JSONDecodeError, ValueError) as exc: + if _wants_json(): + return jsonify({"error": f"Policy error: {exc}"}), 400 flash(f"Policy error: {exc}", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions")) @@ -1005,6 +1036,8 @@ def update_bucket_versioning(bucket_name: str): try: _authorize_ui(principal, bucket_name, "write") except IamError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 403 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) state = request.form.get("state", "enable") @@ -1012,9 +1045,14 @@ def update_bucket_versioning(bucket_name: str): try: _storage().set_bucket_versioning(bucket_name, enable) except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - flash("Versioning enabled" if enable else "Versioning suspended", "success") + message = "Versioning enabled" if enable else "Versioning suspended" + if _wants_json(): + return jsonify({"success": True, "message": message, "enabled": enable}) + flash(message, "success") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) @@ -1022,62 +1060,83 @@ def update_bucket_versioning(bucket_name: str): def update_bucket_quota(bucket_name: str): """Update bucket quota configuration (admin only).""" principal = _current_principal() - + is_admin = False try: _iam().authorize(principal, None, "iam:list_users") is_admin = True except IamError: pass - + if not is_admin: + if _wants_json(): + return jsonify({"error": "Only administrators can manage bucket quotas"}), 403 flash("Only administrators can manage bucket quotas", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + action = request.form.get("action", "set") - + if action == "remove": try: _storage().set_bucket_quota(bucket_name, max_bytes=None, max_objects=None) + if _wants_json(): + return jsonify({"success": True, "message": "Bucket quota removed"}) flash("Bucket quota removed", "info") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + max_mb_str = request.form.get("max_mb", "").strip() max_objects_str = request.form.get("max_objects", "").strip() - + max_bytes = None max_objects = None - + if max_mb_str: try: max_mb = int(max_mb_str) if max_mb < 1: raise ValueError("Size must be at least 1 MB") - max_bytes = max_mb * 1024 * 1024 + max_bytes = max_mb * 1024 * 1024 except ValueError as exc: + if _wants_json(): + return jsonify({"error": f"Invalid size value: {exc}"}), 400 flash(f"Invalid size value: {exc}", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + if max_objects_str: try: max_objects = int(max_objects_str) if max_objects < 0: raise ValueError("Object count must be non-negative") except ValueError as exc: + if _wants_json(): + return jsonify({"error": f"Invalid object count: {exc}"}), 400 flash(f"Invalid object count: {exc}", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + try: _storage().set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects) if max_bytes is None and max_objects is None: - flash("Bucket quota removed", "info") + message = "Bucket quota removed" else: - flash("Bucket quota updated", "success") + message = "Bucket quota updated" + if _wants_json(): + return jsonify({ + "success": True, + "message": message, + "max_bytes": max_bytes, + "max_objects": max_objects, + "has_quota": max_bytes is not None or max_objects is not None + }) + flash(message, "success" if max_bytes or max_objects else "info") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") - + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) @@ -1088,26 +1147,34 @@ def update_bucket_encryption(bucket_name: str): try: _authorize_ui(principal, bucket_name, "write") except IamError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 403 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + action = request.form.get("action", "enable") - + if action == "disable": try: _storage().set_bucket_encryption(bucket_name, None) + if _wants_json(): + return jsonify({"success": True, "message": "Default encryption disabled", "enabled": False}) flash("Default encryption disabled", "info") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + algorithm = request.form.get("algorithm", "AES256") kms_key_id = request.form.get("kms_key_id", "").strip() or None - + if algorithm not in ("AES256", "aws:kms"): + if _wants_json(): + return jsonify({"error": "Invalid encryption algorithm"}), 400 flash("Invalid encryption algorithm", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + encryption_config: dict[str, Any] = { "Rules": [ { @@ -1117,19 +1184,24 @@ def update_bucket_encryption(bucket_name: str): } ] } - + if algorithm == "aws:kms" and kms_key_id: encryption_config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["KMSMasterKeyID"] = kms_key_id - + try: _storage().set_bucket_encryption(bucket_name, encryption_config) if algorithm == "aws:kms": - flash("Default KMS encryption enabled", "success") + message = "Default KMS encryption enabled" else: - flash("Default AES-256 encryption enabled", "success") + message = "Default AES-256 encryption enabled" + if _wants_json(): + return jsonify({"success": True, "message": message, "enabled": True, "algorithm": algorithm}) + flash(message, "success") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") - + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) @@ -1178,10 +1250,14 @@ def create_iam_user(): try: _iam().authorize(principal, None, "iam:create_user") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) display_name = request.form.get("display_name", "").strip() or "Unnamed" if len(display_name) > 64: + if _wants_json(): + return jsonify({"error": "Display name must be 64 characters or fewer"}), 400 flash("Display name must be 64 characters or fewer", "danger") return redirect(url_for("ui.iam_dashboard")) policies_text = request.form.get("policies", "").strip() @@ -1190,11 +1266,15 @@ def create_iam_user(): try: policies = json.loads(policies_text) except json.JSONDecodeError as exc: + if _wants_json(): + return jsonify({"error": f"Invalid JSON: {exc}"}), 400 flash(f"Invalid JSON: {exc}", "danger") return redirect(url_for("ui.iam_dashboard")) try: created = _iam().create_user(display_name=display_name, policies=policies) except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1205,6 +1285,15 @@ def create_iam_user(): "operation": "create", } ) + if _wants_json(): + return jsonify({ + "success": True, + "message": f"Created user {created['access_key']}", + "access_key": created["access_key"], + "secret_key": created["secret_key"], + "display_name": display_name, + "policies": policies or [] + }) flash(f"Created user {created['access_key']}. Copy the secret below.", "success") return redirect(url_for("ui.iam_dashboard", secret_token=token)) @@ -1256,18 +1345,26 @@ def update_iam_user(access_key: str): try: _iam().authorize(principal, None, "iam:create_user") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) display_name = request.form.get("display_name", "").strip() if display_name: if len(display_name) > 64: + if _wants_json(): + return jsonify({"error": "Display name must be 64 characters or fewer"}), 400 flash("Display name must be 64 characters or fewer", "danger") else: try: _iam().update_user(access_key, display_name) + if _wants_json(): + return jsonify({"success": True, "message": f"Updated user {access_key}", "display_name": display_name}) flash(f"Updated user {access_key}", "success") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1279,6 +1376,8 @@ def delete_iam_user(access_key: str): try: _iam().authorize(principal, None, "iam:delete_user") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1286,16 +1385,24 @@ def delete_iam_user(access_key: str): try: _iam().delete_user(access_key) session.pop("credentials", None) + if _wants_json(): + return jsonify({"success": True, "message": "Your account has been deleted", "redirect": url_for("ui.login")}) flash("Your account has been deleted.", "info") return redirect(url_for("ui.login")) except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) try: _iam().delete_user(access_key) + if _wants_json(): + return jsonify({"success": True, "message": f"Deleted user {access_key}"}) flash(f"Deleted user {access_key}", "success") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1306,6 +1413,8 @@ def update_iam_policies(access_key: str): try: _iam().authorize(principal, None, "iam:update_policy") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1318,13 +1427,19 @@ def update_iam_policies(access_key: str): if not isinstance(policies, list): raise ValueError("Policies must be a list") except (ValueError, json.JSONDecodeError): + if _wants_json(): + return jsonify({"error": "Invalid JSON format for policies"}), 400 flash("Invalid JSON format for policies", "danger") return redirect(url_for("ui.iam_dashboard")) try: _iam().update_user_policies(access_key, policies) + if _wants_json(): + return jsonify({"success": True, "message": f"Updated policies for {access_key}", "policies": policies}) flash(f"Updated policies for {access_key}", "success") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1336,19 +1451,23 @@ def create_connection(): try: _iam().authorize(principal, None, "iam:list_users") except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 flash("Access denied", "danger") return redirect(url_for("ui.buckets_overview")) - + name = request.form.get("name", "").strip() endpoint = request.form.get("endpoint_url", "").strip() access_key = request.form.get("access_key", "").strip() secret_key = request.form.get("secret_key", "").strip() region = request.form.get("region", "us-east-1").strip() - + if not all([name, endpoint, access_key, secret_key]): + if _wants_json(): + return jsonify({"error": "All fields are required"}), 400 flash("All fields are required", "danger") return redirect(url_for("ui.connections_dashboard")) - + conn = RemoteConnection( id=str(uuid.uuid4()), name=name, @@ -1358,6 +1477,8 @@ def create_connection(): region=region ) _connections().add(conn) + if _wants_json(): + return jsonify({"success": True, "message": f"Connection '{name}' created", "connection_id": conn.id}) flash(f"Connection '{name}' created", "success") return redirect(url_for("ui.connections_dashboard")) @@ -1417,11 +1538,15 @@ def update_connection(connection_id: str): try: _iam().authorize(principal, None, "iam:list_users") except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 flash("Access denied", "danger") return redirect(url_for("ui.buckets_overview")) conn = _connections().get(connection_id) if not conn: + if _wants_json(): + return jsonify({"error": "Connection not found"}), 404 flash("Connection not found", "danger") return redirect(url_for("ui.connections_dashboard")) @@ -1432,6 +1557,8 @@ def update_connection(connection_id: str): region = request.form.get("region", "us-east-1").strip() if not all([name, endpoint, access_key, secret_key]): + if _wants_json(): + return jsonify({"error": "All fields are required"}), 400 flash("All fields are required", "danger") return redirect(url_for("ui.connections_dashboard")) @@ -1440,8 +1567,20 @@ def update_connection(connection_id: str): conn.access_key = access_key conn.secret_key = secret_key conn.region = region - + _connections().save() + if _wants_json(): + return jsonify({ + "success": True, + "message": f"Connection '{name}' updated", + "connection": { + "id": connection_id, + "name": name, + "endpoint_url": endpoint, + "access_key": access_key, + "region": region + } + }) flash(f"Connection '{name}' updated", "success") return redirect(url_for("ui.connections_dashboard")) @@ -1452,10 +1591,14 @@ def delete_connection(connection_id: str): try: _iam().authorize(principal, None, "iam:list_users") except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 flash("Access denied", "danger") return redirect(url_for("ui.buckets_overview")) - + _connections().delete(connection_id) + if _wants_json(): + return jsonify({"success": True, "message": "Connection deleted"}) flash("Connection deleted", "success") return redirect(url_for("ui.connections_dashboard")) @@ -1466,31 +1609,41 @@ def update_bucket_replication(bucket_name: str): try: _authorize_ui(principal, bucket_name, "replication") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) - + is_admin = False try: _iam().authorize(principal, None, "iam:list_users") is_admin = True except IamError: is_admin = False - + action = request.form.get("action") - + if action == "delete": if not is_admin: + if _wants_json(): + return jsonify({"error": "Only administrators can remove replication configuration"}), 403 flash("Only administrators can remove replication configuration", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) _replication().delete_rule(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": "Replication configuration removed", "action": "delete"}) flash("Replication configuration removed", "info") elif action == "pause": rule = _replication().get_rule(bucket_name) if rule: rule.enabled = False _replication().set_rule(rule) + if _wants_json(): + return jsonify({"success": True, "message": "Replication paused", "action": "pause", "enabled": False}) flash("Replication paused", "info") else: + if _wants_json(): + return jsonify({"error": "No replication configuration to pause"}), 404 flash("No replication configuration to pause", "warning") elif action == "resume": from .replication import REPLICATION_MODE_ALL @@ -1500,24 +1653,33 @@ def update_bucket_replication(bucket_name: str): _replication().set_rule(rule) if rule.mode == REPLICATION_MODE_ALL: _replication().replicate_existing_objects(bucket_name) - flash("Replication resumed. Syncing pending objects in background.", "success") + message = "Replication resumed. Syncing pending objects in background." else: - flash("Replication resumed", "success") + message = "Replication resumed" + if _wants_json(): + return jsonify({"success": True, "message": message, "action": "resume", "enabled": True}) + flash(message, "success") else: + if _wants_json(): + return jsonify({"error": "No replication configuration to resume"}), 404 flash("No replication configuration to resume", "warning") elif action == "create": if not is_admin: + if _wants_json(): + return jsonify({"error": "Only administrators can configure replication settings"}), 403 flash("Only administrators can configure replication settings", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) - + from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL import time - + target_conn_id = request.form.get("target_connection_id") target_bucket = request.form.get("target_bucket", "").strip() replication_mode = request.form.get("replication_mode", REPLICATION_MODE_NEW_ONLY) - + if not target_conn_id or not target_bucket: + if _wants_json(): + return jsonify({"error": "Target connection and bucket are required"}), 400 flash("Target connection and bucket are required", "danger") else: rule = ReplicationRule( @@ -1529,15 +1691,20 @@ def update_bucket_replication(bucket_name: str): created_at=time.time(), ) _replication().set_rule(rule) - + if replication_mode == REPLICATION_MODE_ALL: _replication().replicate_existing_objects(bucket_name) - flash("Replication configured. Existing objects are being replicated in the background.", "success") + message = "Replication configured. Existing objects are being replicated in the background." else: - flash("Replication configured. Only new uploads will be replicated.", "success") + message = "Replication configured. Only new uploads will be replicated." + if _wants_json(): + return jsonify({"success": True, "message": message, "action": "create", "enabled": True}) + flash(message, "success") else: + if _wants_json(): + return jsonify({"error": "Invalid action"}), 400 flash("Invalid action", "danger") - + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) @@ -1769,6 +1936,67 @@ def metrics_dashboard(): ) +@ui_bp.route("/metrics/api") +def metrics_api(): + principal = _current_principal() + + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + return jsonify({"error": "Access denied"}), 403 + + import time + + cpu_percent = psutil.cpu_percent(interval=0.1) + memory = psutil.virtual_memory() + + storage_root = current_app.config["STORAGE_ROOT"] + disk = psutil.disk_usage(storage_root) + + storage = _storage() + buckets = storage.list_buckets() + total_buckets = len(buckets) + + total_objects = 0 + total_bytes_used = 0 + total_versions = 0 + + cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60) + for bucket in buckets: + stats = storage.bucket_stats(bucket.name, cache_ttl=cache_ttl) + total_objects += stats.get("total_objects", stats.get("objects", 0)) + total_bytes_used += stats.get("total_bytes", stats.get("bytes", 0)) + total_versions += stats.get("version_count", 0) + + boot_time = psutil.boot_time() + uptime_seconds = time.time() - boot_time + uptime_days = int(uptime_seconds / 86400) + + return jsonify({ + "cpu_percent": cpu_percent, + "memory": { + "total": _format_bytes(memory.total), + "available": _format_bytes(memory.available), + "used": _format_bytes(memory.used), + "percent": memory.percent, + }, + "disk": { + "total": _format_bytes(disk.total), + "free": _format_bytes(disk.free), + "used": _format_bytes(disk.used), + "percent": disk.percent, + }, + "app": { + "buckets": total_buckets, + "objects": total_objects, + "versions": total_versions, + "storage_used": _format_bytes(total_bytes_used), + "storage_raw": total_bytes_used, + "uptime_days": uptime_days, + } + }) + + @ui_bp.route("/buckets//lifecycle", methods=["GET", "POST", "DELETE"]) def bucket_lifecycle(bucket_name: str): principal = _current_principal() diff --git a/static/js/bucket-detail-main.js b/static/js/bucket-detail-main.js index ec8d134..cca821f 100644 --- a/static/js/bucket-detail-main.js +++ b/static/js/bucket-detail-main.js @@ -525,7 +525,7 @@ const deleteObjectForm = document.getElementById('deleteObjectForm'); const deleteObjectKey = document.getElementById('deleteObjectKey'); if (deleteModal && deleteObjectForm) { - deleteObjectForm.action = row.dataset.deleteEndpoint; + deleteObjectForm.setAttribute('action', row.dataset.deleteEndpoint); if (deleteObjectKey) deleteObjectKey.textContent = row.dataset.key; deleteModal.show(); } @@ -866,6 +866,10 @@ }; const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => { + if (!actionText && !onAction && window.showToast) { + window.showToast(body || title, title, variant); + return; + } if (!messageModal) { window.alert(body || title); return; @@ -1147,7 +1151,11 @@ } const summary = messageParts.length ? messageParts.join(', ') : 'Bulk delete finished'; showMessage({ title: 'Bulk delete complete', body: data.message || summary, variant: errorCount ? 'warning' : 'success' }); - window.setTimeout(() => window.location.reload(), 600); + selectedRows.clear(); + previewEmpty.classList.remove('d-none'); + previewPanel.classList.add('d-none'); + activeRow = null; + loadObjects(false); } catch (error) { bulkDeleteModal?.hide(); showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete selected objects', variant: 'danger' }); @@ -1377,7 +1385,7 @@ } showMessage({ title: 'Restore scheduled', body: data.message || 'Object restored from archive.', variant: 'success' }); await loadArchivedObjects(); - window.setTimeout(() => window.location.reload(), 600); + loadObjects(false); } catch (error) { showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore archived object', variant: 'danger' }); } @@ -1470,7 +1478,7 @@ } await loadObjectVersions(row, { force: true }); showMessage({ title: 'Version restored', body: data.message || 'The selected version has been restored.', variant: 'success' }); - window.setTimeout(() => window.location.reload(), 500); + loadObjects(false); } catch (error) { showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore version', variant: 'danger' }); } @@ -1563,6 +1571,54 @@ const deleteObjectForm = document.getElementById('deleteObjectForm'); const deleteObjectKey = document.getElementById('deleteObjectKey'); + if (deleteObjectForm) { + deleteObjectForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = deleteObjectForm.querySelector('[type="submit"]'); + const originalHtml = submitBtn ? submitBtn.innerHTML : ''; + try { + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Deleting...'; + } + const formData = new FormData(deleteObjectForm); + const csrfToken = formData.get('csrf_token') || (window.getCsrfToken ? window.getCsrfToken() : ''); + const formAction = deleteObjectForm.getAttribute('action'); + const response = await fetch(formAction, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData + }); + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Server returned an unexpected response. Please try again.'); + } + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Unable to delete object'); + } + if (deleteModal) deleteModal.hide(); + showMessage({ title: 'Object deleted', body: data.message || 'The object has been deleted.', variant: 'success' }); + previewEmpty.classList.remove('d-none'); + previewPanel.classList.add('d-none'); + activeRow = null; + loadObjects(false); + } catch (err) { + if (deleteModal) deleteModal.hide(); + showMessage({ title: 'Delete failed', body: err.message || 'Unable to delete object', variant: 'danger' }); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = originalHtml; + } + } + }); + } + const resetPreviewMedia = () => { [previewImage, previewVideo, previewIframe].forEach((el) => { el.classList.add('d-none'); @@ -2234,6 +2290,7 @@ const finishUploadSession = () => { if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none'); if (bulkUploadResults) bulkUploadResults.classList.remove('d-none'); + hideFloatingProgress(); if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = uploadSuccessFiles.length; if (uploadSuccessFiles.length === 0 && bulkUploadSuccessAlert) { @@ -2256,13 +2313,22 @@ updateUploadBtnText(); updateQueueListDisplay(); - if (uploadSuccessFiles.length > 0) { - if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...'; - const objectsTabUrl = window.location.pathname + '?tab=objects'; - window.setTimeout(() => window.location.href = objectsTabUrl, 800); - } else { - if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; - if (uploadFileInput) uploadFileInput.disabled = false; + if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; + if (uploadFileInput) { + uploadFileInput.disabled = false; + uploadFileInput.value = ''; + } + + loadObjects(false); + + const successCount = uploadSuccessFiles.length; + const errorCount = uploadErrorFiles.length; + if (successCount > 0 && errorCount > 0) { + showMessage({ title: 'Upload complete', body: `${successCount} uploaded, ${errorCount} failed.`, variant: 'warning' }); + } else if (successCount > 0) { + showMessage({ title: 'Upload complete', body: `${successCount} object(s) uploaded successfully.`, variant: 'success' }); + } else if (errorCount > 0) { + showMessage({ title: 'Upload failed', body: `${errorCount} file(s) failed to upload.`, variant: 'danger' }); } }; @@ -2300,6 +2366,10 @@ if (uploadSubmitBtn) uploadSubmitBtn.disabled = true; refreshUploadDropLabel(); updateUploadBtnText(); + + if (uploadModal) uploadModal.hide(); + showFloatingProgress(); + showMessage({ title: 'Upload started', body: `Uploading ${files.length} file(s)...`, variant: 'info' }); } const fileCount = files.length; @@ -2610,6 +2680,10 @@ loadReplicationStats(); + if (window.pollingManager) { + window.pollingManager.start('replication', loadReplicationStats); + } + const refreshBtn = document.querySelector('[data-refresh-replication]'); refreshBtn?.addEventListener('click', () => { @@ -3407,7 +3481,12 @@ if (!resp.ok) throw new Error(data.error || `Failed to ${copyMoveAction} object`); showMessage({ title: `Object ${copyMoveAction === 'move' ? 'moved' : 'copied'}`, body: `Successfully ${copyMoveAction === 'move' ? 'moved' : 'copied'} to ${destBucket}/${destKey}`, variant: 'success' }); copyMoveModal?.hide(); - if (copyMoveAction === 'move') window.setTimeout(() => window.location.reload(), 500); + if (copyMoveAction === 'move') { + previewEmpty.classList.remove('d-none'); + previewPanel.classList.add('d-none'); + activeRow = null; + loadObjects(false); + } } catch (err) { showMessage({ title: `${copyMoveAction === 'move' ? 'Move' : 'Copy'} failed`, body: err.message, variant: 'danger' }); } @@ -3495,9 +3574,383 @@ loadLifecycleHistory(); }); - if (lifecycleHistoryCard) loadLifecycleHistory(); + if (lifecycleHistoryCard) { + loadLifecycleHistory(); + if (window.pollingManager) { + window.pollingManager.start('lifecycle', loadLifecycleHistory); + } + } if (corsCard) loadCorsRules(); if (aclCard) loadAcl(); + function updateVersioningBadge(enabled) { + var badge = document.querySelector('.badge.rounded-pill'); + if (!badge) return; + badge.classList.remove('text-bg-success', 'text-bg-secondary'); + badge.classList.add(enabled ? 'text-bg-success' : 'text-bg-secondary'); + var icon = '' + + '' + + ''; + badge.innerHTML = icon + (enabled ? 'Versioning On' : 'Versioning Off'); + versioningEnabled = enabled; + } + + function interceptForm(formId, options) { + var form = document.getElementById(formId); + if (!form) return; + + form.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(form, { + successMessage: options.successMessage || 'Operation completed', + onSuccess: function(data) { + if (options.onSuccess) options.onSuccess(data); + if (options.closeModal) { + var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal)); + if (modal) modal.hide(); + } + if (options.reload) { + setTimeout(function() { location.reload(); }, 500); + } + } + }); + }); + } + + function updateVersioningCard(enabled) { + var card = document.getElementById('bucket-versioning-card'); + if (!card) return; + var cardBody = card.querySelector('.card-body'); + if (!cardBody) return; + + var enabledHtml = '' + + ''; + + var disabledHtml = '' + + '
' + + '' + + '' + + '
'; + + cardBody.innerHTML = enabled ? enabledHtml : disabledHtml; + + var archivedCardEl = document.getElementById('archived-objects-card'); + if (archivedCardEl) { + archivedCardEl.style.display = enabled ? '' : 'none'; + } + + var dropZone = document.getElementById('objects-drop-zone'); + if (dropZone) { + dropZone.setAttribute('data-versioning', enabled ? 'true' : 'false'); + } + + if (!enabled) { + var newForm = document.getElementById('enableVersioningForm'); + if (newForm) { + newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || ''); + newForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(newForm, { + successMessage: 'Versioning enabled', + onSuccess: function() { + updateVersioningBadge(true); + updateVersioningCard(true); + } + }); + }); + } + } + } + + function updateEncryptionCard(enabled, algorithm) { + var encCard = document.getElementById('bucket-encryption-card'); + if (!encCard) return; + var alertContainer = encCard.querySelector('.alert'); + if (alertContainer) { + if (enabled) { + alertContainer.className = 'alert alert-success d-flex align-items-start mb-4'; + var algoText = algorithm === 'aws:kms' ? 'KMS' : 'AES-256'; + alertContainer.innerHTML = '' + + '' + + '' + + '
Default encryption enabled (' + algoText + ')' + + '

All new objects uploaded to this bucket will be automatically encrypted.

'; + } else { + alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '
Default encryption disabled' + + '

Objects are stored without default encryption. You can enable server-side encryption below.

'; + } + } + var disableBtn = document.getElementById('disableEncryptionBtn'); + if (disableBtn) { + disableBtn.style.display = enabled ? '' : 'none'; + } + } + + function updateQuotaCard(hasQuota, maxBytes, maxObjects) { + var quotaCard = document.getElementById('bucket-quota-card'); + if (!quotaCard) return; + var alertContainer = quotaCard.querySelector('.alert'); + if (alertContainer) { + if (hasQuota) { + alertContainer.className = 'alert alert-info d-flex align-items-start mb-4'; + var quotaParts = []; + if (maxBytes) quotaParts.push(formatBytes(maxBytes) + ' storage'); + if (maxObjects) quotaParts.push(maxObjects.toLocaleString() + ' objects'); + alertContainer.innerHTML = '' + + '' + + '
Storage quota active' + + '

This bucket is limited to ' + quotaParts.join(' and ') + '.

'; + } else { + alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '' + + '
No storage quota' + + '

This bucket has no storage or object count limits. Set limits below to control usage.

'; + } + } + var removeBtn = document.getElementById('removeQuotaBtn'); + if (removeBtn) { + removeBtn.style.display = hasQuota ? '' : 'none'; + } + var maxMbInput = document.getElementById('max_mb'); + var maxObjInput = document.getElementById('max_objects'); + if (maxMbInput) maxMbInput.value = maxBytes ? Math.floor(maxBytes / 1048576) : ''; + if (maxObjInput) maxObjInput.value = maxObjects || ''; + } + + function updatePolicyCard(hasPolicy, preset) { + var policyCard = document.querySelector('#permissions-pane .card'); + if (!policyCard) return; + var alertContainer = policyCard.querySelector('.alert'); + if (alertContainer) { + if (hasPolicy) { + alertContainer.className = 'alert alert-info d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '
Policy attached' + + '

A bucket policy is attached to this bucket. Access is granted via both IAM and bucket policy rules.

'; + } else { + alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '' + + '
IAM only' + + '

No bucket policy is attached. Access is controlled by IAM policies only.

'; + } + } + document.querySelectorAll('.preset-btn').forEach(function(btn) { + btn.classList.remove('active'); + if (btn.dataset.preset === preset) btn.classList.add('active'); + }); + var presetInputEl = document.getElementById('policyPreset'); + if (presetInputEl) presetInputEl.value = preset; + var deletePolicyBtn = document.getElementById('deletePolicyBtn'); + if (deletePolicyBtn) { + deletePolicyBtn.style.display = hasPolicy ? '' : 'none'; + } + } + + interceptForm('enableVersioningForm', { + successMessage: 'Versioning enabled', + onSuccess: function(data) { + updateVersioningBadge(true); + updateVersioningCard(true); + } + }); + + interceptForm('suspendVersioningForm', { + successMessage: 'Versioning suspended', + closeModal: 'suspendVersioningModal', + onSuccess: function(data) { + updateVersioningBadge(false); + updateVersioningCard(false); + } + }); + + interceptForm('encryptionForm', { + successMessage: 'Encryption settings saved', + onSuccess: function(data) { + updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256'); + } + }); + + interceptForm('quotaForm', { + successMessage: 'Quota settings saved', + onSuccess: function(data) { + updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects); + } + }); + + interceptForm('bucketPolicyForm', { + successMessage: 'Bucket policy saved', + onSuccess: function(data) { + var policyModeEl = document.getElementById('policyMode'); + var policyPresetEl = document.getElementById('policyPreset'); + var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' : + (policyPresetEl?.value || 'custom'); + updatePolicyCard(preset !== 'private', preset); + } + }); + + var deletePolicyForm = document.getElementById('deletePolicyForm'); + if (deletePolicyForm) { + deletePolicyForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(deletePolicyForm, { + successMessage: 'Bucket policy deleted', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal')); + if (modal) modal.hide(); + updatePolicyCard(false, 'private'); + var policyTextarea = document.getElementById('policyDocument'); + if (policyTextarea) policyTextarea.value = ''; + } + }); + }); + } + + var disableEncBtn = document.getElementById('disableEncryptionBtn'); + if (disableEncBtn) { + disableEncBtn.addEventListener('click', function() { + var form = document.getElementById('encryptionForm'); + if (!form) return; + document.getElementById('encryptionAction').value = 'disable'; + window.UICore.submitFormAjax(form, { + successMessage: 'Encryption disabled', + onSuccess: function(data) { + document.getElementById('encryptionAction').value = 'enable'; + updateEncryptionCard(false, null); + } + }); + }); + } + + var removeQuotaBtn = document.getElementById('removeQuotaBtn'); + if (removeQuotaBtn) { + removeQuotaBtn.addEventListener('click', function() { + var form = document.getElementById('quotaForm'); + if (!form) return; + document.getElementById('quotaAction').value = 'remove'; + window.UICore.submitFormAjax(form, { + successMessage: 'Quota removed', + onSuccess: function(data) { + document.getElementById('quotaAction').value = 'set'; + updateQuotaCard(false, null, null); + } + }); + }); + } + + function reloadReplicationPane() { + var replicationPane = document.getElementById('replication-pane'); + if (!replicationPane) return; + fetch(window.location.pathname + '?tab=replication', { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(resp) { return resp.text(); }) + .then(function(html) { + var parser = new DOMParser(); + var doc = parser.parseFromString(html, 'text/html'); + var newPane = doc.getElementById('replication-pane'); + if (newPane) { + replicationPane.innerHTML = newPane.innerHTML; + initReplicationForms(); + initReplicationStats(); + } + }) + .catch(function(err) { + console.error('Failed to reload replication pane:', err); + }); + } + + function initReplicationForms() { + document.querySelectorAll('form[action*="replication"]').forEach(function(form) { + if (form.dataset.ajaxBound) return; + form.dataset.ajaxBound = 'true'; + var actionInput = form.querySelector('input[name="action"]'); + if (!actionInput) return; + var action = actionInput.value; + + form.addEventListener('submit', function(e) { + e.preventDefault(); + var msg = action === 'pause' ? 'Replication paused' : + action === 'resume' ? 'Replication resumed' : + action === 'delete' ? 'Replication disabled' : + action === 'create' ? 'Replication configured' : 'Operation completed'; + window.UICore.submitFormAjax(form, { + successMessage: msg, + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal')); + if (modal) modal.hide(); + reloadReplicationPane(); + } + }); + }); + }); + } + + function initReplicationStats() { + var statsContainer = document.getElementById('replication-stats-cards'); + if (!statsContainer) return; + var statusEndpoint = statsContainer.dataset.statusEndpoint; + if (!statusEndpoint) return; + + var syncedEl = statsContainer.querySelector('[data-stat="synced"]'); + var pendingEl = statsContainer.querySelector('[data-stat="pending"]'); + var orphanedEl = statsContainer.querySelector('[data-stat="orphaned"]'); + var bytesEl = statsContainer.querySelector('[data-stat="bytes"]'); + + fetch(statusEndpoint) + .then(function(resp) { return resp.json(); }) + .then(function(data) { + if (syncedEl) syncedEl.textContent = data.objects_synced || 0; + if (pendingEl) pendingEl.textContent = data.objects_pending || 0; + if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0; + if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0); + }) + .catch(function(err) { + console.error('Failed to load replication stats:', err); + }); + } + + initReplicationForms(); + initReplicationStats(); + + var deleteBucketForm = document.getElementById('deleteBucketForm'); + if (deleteBucketForm) { + deleteBucketForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(deleteBucketForm, { + successMessage: 'Bucket deleted', + onSuccess: function() { + window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets'; + } + }); + }); + } + + window.BucketDetailConfig = window.BucketDetailConfig || {}; + })(); diff --git a/static/js/connections-management.js b/static/js/connections-management.js new file mode 100644 index 0000000..e58b0be --- /dev/null +++ b/static/js/connections-management.js @@ -0,0 +1,344 @@ +window.ConnectionsManagement = (function() { + 'use strict'; + + var endpoints = {}; + var csrfToken = ''; + + function init(config) { + endpoints = config.endpoints || {}; + csrfToken = config.csrfToken || ''; + + setupEventListeners(); + checkAllConnectionHealth(); + } + + function togglePassword(id) { + var input = document.getElementById(id); + if (input) { + input.type = input.type === 'password' ? 'text' : 'password'; + } + } + + async function testConnection(formId, resultId) { + var form = document.getElementById(formId); + var resultDiv = document.getElementById(resultId); + if (!form || !resultDiv) return; + + var formData = new FormData(form); + var data = {}; + formData.forEach(function(value, key) { + if (key !== 'csrf_token') { + data[key] = value; + } + }); + + resultDiv.innerHTML = '
Testing connection...
'; + + var controller = new AbortController(); + var timeoutId = setTimeout(function() { controller.abort(); }, 20000); + + try { + var response = await fetch(endpoints.test, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify(data), + signal: controller.signal + }); + clearTimeout(timeoutId); + + var result = await response.json(); + if (response.ok) { + resultDiv.innerHTML = '
' + + '' + + '' + + '' + window.UICore.escapeHtml(result.message) + '
'; + } else { + resultDiv.innerHTML = '
' + + '' + + '' + + '' + window.UICore.escapeHtml(result.message) + '
'; + } + } catch (error) { + clearTimeout(timeoutId); + var message = error.name === 'AbortError' + ? 'Connection test timed out - endpoint may be unreachable' + : 'Connection failed: Network error'; + resultDiv.innerHTML = '
' + + '' + + '' + + '' + message + '
'; + } + } + + async function checkConnectionHealth(connectionId, statusEl) { + if (!statusEl) return; + + try { + var controller = new AbortController(); + var timeoutId = setTimeout(function() { controller.abort(); }, 15000); + + var response = await fetch(endpoints.healthTemplate.replace('CONNECTION_ID', connectionId), { + signal: controller.signal + }); + clearTimeout(timeoutId); + + var data = await response.json(); + if (data.healthy) { + statusEl.innerHTML = '' + + ''; + statusEl.setAttribute('data-status', 'healthy'); + statusEl.setAttribute('title', 'Connected'); + } else { + statusEl.innerHTML = '' + + ''; + statusEl.setAttribute('data-status', 'unhealthy'); + statusEl.setAttribute('title', data.error || 'Unreachable'); + } + } catch (error) { + statusEl.innerHTML = '' + + ''; + statusEl.setAttribute('data-status', 'unknown'); + statusEl.setAttribute('title', 'Could not check status'); + } + } + + function checkAllConnectionHealth() { + var rows = document.querySelectorAll('tr[data-connection-id]'); + rows.forEach(function(row, index) { + var connectionId = row.getAttribute('data-connection-id'); + var statusEl = row.querySelector('.connection-status'); + if (statusEl) { + setTimeout(function() { + checkConnectionHealth(connectionId, statusEl); + }, index * 200); + } + }); + } + + function updateConnectionCount() { + var countBadge = document.querySelector('.badge.bg-primary.bg-opacity-10.text-primary.fs-6'); + if (countBadge) { + var remaining = document.querySelectorAll('tr[data-connection-id]').length; + countBadge.textContent = remaining + ' connection' + (remaining !== 1 ? 's' : ''); + } + } + + function createConnectionRowHtml(conn) { + var ak = conn.access_key || ''; + var maskedKey = ak.length > 12 ? ak.slice(0, 8) + '...' + ak.slice(-4) : ak; + + return '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + window.UICore.escapeHtml(conn.name) + '' + + '
' + + '' + window.UICore.escapeHtml(conn.endpoint_url) + '' + + '' + window.UICore.escapeHtml(conn.region) + '' + + '' + window.UICore.escapeHtml(maskedKey) + '' + + '
' + + '' + + '' + + '
'; + } + + function setupEventListeners() { + var testBtn = document.getElementById('testConnectionBtn'); + if (testBtn) { + testBtn.addEventListener('click', function() { + testConnection('createConnectionForm', 'testResult'); + }); + } + + var editTestBtn = document.getElementById('editTestConnectionBtn'); + if (editTestBtn) { + editTestBtn.addEventListener('click', function() { + testConnection('editConnectionForm', 'editTestResult'); + }); + } + + var editModal = document.getElementById('editConnectionModal'); + if (editModal) { + editModal.addEventListener('show.bs.modal', function(event) { + var button = event.relatedTarget; + if (!button) return; + + var 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 = ''; + + var form = document.getElementById('editConnectionForm'); + form.action = endpoints.updateTemplate.replace('CONNECTION_ID', id); + }); + } + + var deleteModal = document.getElementById('deleteConnectionModal'); + if (deleteModal) { + deleteModal.addEventListener('show.bs.modal', function(event) { + var button = event.relatedTarget; + if (!button) return; + + var id = button.getAttribute('data-id'); + var name = button.getAttribute('data-name'); + + document.getElementById('deleteConnectionName').textContent = name; + var form = document.getElementById('deleteConnectionForm'); + form.action = endpoints.deleteTemplate.replace('CONNECTION_ID', id); + }); + } + + var createForm = document.getElementById('createConnectionForm'); + if (createForm) { + createForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(createForm, { + successMessage: 'Connection created', + onSuccess: function(data) { + createForm.reset(); + document.getElementById('testResult').innerHTML = ''; + + if (data.connection) { + var emptyState = document.querySelector('.empty-state'); + if (emptyState) { + var cardBody = emptyState.closest('.card-body'); + if (cardBody) { + cardBody.innerHTML = '
' + + '' + + '' + + '' + + '' + + '' + + '
StatusNameEndpointRegionAccess KeyActions
'; + } + } + + var tbody = document.querySelector('table tbody'); + if (tbody) { + tbody.insertAdjacentHTML('beforeend', createConnectionRowHtml(data.connection)); + var newRow = tbody.lastElementChild; + var statusEl = newRow.querySelector('.connection-status'); + if (statusEl) { + checkConnectionHealth(data.connection.id, statusEl); + } + } + updateConnectionCount(); + } else { + location.reload(); + } + } + }); + }); + } + + var editForm = document.getElementById('editConnectionForm'); + if (editForm) { + editForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(editForm, { + successMessage: 'Connection updated', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('editConnectionModal')); + if (modal) modal.hide(); + + var connId = editForm.action.split('/').slice(-2)[0]; + var row = document.querySelector('tr[data-connection-id="' + connId + '"]'); + if (row && data.connection) { + var nameCell = row.querySelector('.fw-medium'); + if (nameCell) nameCell.textContent = data.connection.name; + + var endpointCell = row.querySelector('.text-truncate'); + if (endpointCell) { + endpointCell.textContent = data.connection.endpoint_url; + endpointCell.title = data.connection.endpoint_url; + } + + var regionBadge = row.querySelector('.badge.bg-primary'); + if (regionBadge) regionBadge.textContent = data.connection.region; + + var accessCode = row.querySelector('code.small'); + if (accessCode && data.connection.access_key) { + var ak = data.connection.access_key; + accessCode.textContent = ak.slice(0, 8) + '...' + ak.slice(-4); + } + + var editBtn = row.querySelector('[data-bs-target="#editConnectionModal"]'); + if (editBtn) { + editBtn.setAttribute('data-name', data.connection.name); + editBtn.setAttribute('data-endpoint', data.connection.endpoint_url); + editBtn.setAttribute('data-region', data.connection.region); + editBtn.setAttribute('data-access', data.connection.access_key); + if (data.connection.secret_key) { + editBtn.setAttribute('data-secret', data.connection.secret_key); + } + } + + var deleteBtn = row.querySelector('[data-bs-target="#deleteConnectionModal"]'); + if (deleteBtn) { + deleteBtn.setAttribute('data-name', data.connection.name); + } + + var statusEl = row.querySelector('.connection-status'); + if (statusEl) { + checkConnectionHealth(connId, statusEl); + } + } + } + }); + }); + } + + var deleteForm = document.getElementById('deleteConnectionForm'); + if (deleteForm) { + deleteForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(deleteForm, { + successMessage: 'Connection deleted', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('deleteConnectionModal')); + if (modal) modal.hide(); + + var connId = deleteForm.action.split('/').slice(-2)[0]; + var row = document.querySelector('tr[data-connection-id="' + connId + '"]'); + if (row) { + row.remove(); + } + + updateConnectionCount(); + + if (document.querySelectorAll('tr[data-connection-id]').length === 0) { + location.reload(); + } + } + }); + }); + } + } + + return { + init: init, + togglePassword: togglePassword, + testConnection: testConnection, + checkConnectionHealth: checkConnectionHealth + }; +})(); diff --git a/static/js/iam-management.js b/static/js/iam-management.js new file mode 100644 index 0000000..11b41fb --- /dev/null +++ b/static/js/iam-management.js @@ -0,0 +1,545 @@ +window.IAMManagement = (function() { + 'use strict'; + + var users = []; + var currentUserKey = null; + var endpoints = {}; + var csrfToken = ''; + var iamLocked = false; + + var policyModal = null; + var editUserModal = null; + var deleteUserModal = null; + var rotateSecretModal = null; + var currentRotateKey = null; + var currentEditKey = null; + var currentDeleteKey = null; + + var policyTemplates = { + 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'] }] + }; + + function init(config) { + users = config.users || []; + currentUserKey = config.currentUserKey || null; + endpoints = config.endpoints || {}; + csrfToken = config.csrfToken || ''; + iamLocked = config.iamLocked || false; + + if (iamLocked) return; + + initModals(); + setupJsonAutoIndent(); + setupCopyButtons(); + setupPolicyEditor(); + setupCreateUserModal(); + setupEditUserModal(); + setupDeleteUserModal(); + setupRotateSecretModal(); + setupFormHandlers(); + } + + function initModals() { + var policyModalEl = document.getElementById('policyEditorModal'); + var editModalEl = document.getElementById('editUserModal'); + var deleteModalEl = document.getElementById('deleteUserModal'); + var rotateModalEl = document.getElementById('rotateSecretModal'); + + if (policyModalEl) policyModal = new bootstrap.Modal(policyModalEl); + if (editModalEl) editUserModal = new bootstrap.Modal(editModalEl); + if (deleteModalEl) deleteUserModal = new bootstrap.Modal(deleteModalEl); + if (rotateModalEl) rotateSecretModal = new bootstrap.Modal(rotateModalEl); + } + + function setupJsonAutoIndent() { + window.UICore.setupJsonAutoIndent(document.getElementById('policyEditorDocument')); + window.UICore.setupJsonAutoIndent(document.getElementById('createUserPolicies')); + } + + function setupCopyButtons() { + document.querySelectorAll('.config-copy').forEach(function(button) { + button.addEventListener('click', async function() { + var targetId = button.dataset.copyTarget; + var target = document.getElementById(targetId); + if (!target) return; + await window.UICore.copyToClipboard(target.innerText, button, 'Copy JSON'); + }); + }); + + var secretCopyButton = document.querySelector('[data-secret-copy]'); + if (secretCopyButton) { + secretCopyButton.addEventListener('click', async function() { + var secretInput = document.getElementById('disclosedSecretValue'); + if (!secretInput) return; + await window.UICore.copyToClipboard(secretInput.value, secretCopyButton, 'Copy'); + }); + } + } + + function getUserPolicies(accessKey) { + var user = users.find(function(u) { return u.access_key === accessKey; }); + return user ? JSON.stringify(user.policies, null, 2) : ''; + } + + function applyPolicyTemplate(name, textareaEl) { + if (policyTemplates[name] && textareaEl) { + textareaEl.value = JSON.stringify(policyTemplates[name], null, 2); + } + } + + function setupPolicyEditor() { + var userLabelEl = document.getElementById('policyEditorUserLabel'); + var userInputEl = document.getElementById('policyEditorUser'); + var textareaEl = document.getElementById('policyEditorDocument'); + + document.querySelectorAll('[data-policy-template]').forEach(function(button) { + button.addEventListener('click', function() { + applyPolicyTemplate(button.dataset.policyTemplate, textareaEl); + }); + }); + + document.querySelectorAll('[data-policy-editor]').forEach(function(button) { + button.addEventListener('click', function() { + var key = button.getAttribute('data-access-key'); + if (!key) return; + + userLabelEl.textContent = key; + userInputEl.value = key; + textareaEl.value = getUserPolicies(key); + + policyModal.show(); + }); + }); + } + + function setupCreateUserModal() { + var createUserPoliciesEl = document.getElementById('createUserPolicies'); + + document.querySelectorAll('[data-create-policy-template]').forEach(function(button) { + button.addEventListener('click', function() { + applyPolicyTemplate(button.dataset.createPolicyTemplate, createUserPoliciesEl); + }); + }); + } + + function setupEditUserModal() { + var editUserForm = document.getElementById('editUserForm'); + var editUserDisplayName = document.getElementById('editUserDisplayName'); + + document.querySelectorAll('[data-edit-user]').forEach(function(btn) { + btn.addEventListener('click', function() { + var key = btn.dataset.editUser; + var name = btn.dataset.displayName; + currentEditKey = key; + editUserDisplayName.value = name; + editUserForm.action = endpoints.updateUser.replace('ACCESS_KEY', key); + editUserModal.show(); + }); + }); + } + + function setupDeleteUserModal() { + var deleteUserForm = document.getElementById('deleteUserForm'); + var deleteUserLabel = document.getElementById('deleteUserLabel'); + var deleteSelfWarning = document.getElementById('deleteSelfWarning'); + + document.querySelectorAll('[data-delete-user]').forEach(function(btn) { + btn.addEventListener('click', function() { + var key = btn.dataset.deleteUser; + currentDeleteKey = key; + deleteUserLabel.textContent = key; + deleteUserForm.action = endpoints.deleteUser.replace('ACCESS_KEY', key); + + if (key === currentUserKey) { + deleteSelfWarning.classList.remove('d-none'); + } else { + deleteSelfWarning.classList.add('d-none'); + } + + deleteUserModal.show(); + }); + }); + } + + function setupRotateSecretModal() { + var rotateUserLabel = document.getElementById('rotateUserLabel'); + var confirmRotateBtn = document.getElementById('confirmRotateBtn'); + var rotateCancelBtn = document.getElementById('rotateCancelBtn'); + var rotateDoneBtn = document.getElementById('rotateDoneBtn'); + var rotateSecretConfirm = document.getElementById('rotateSecretConfirm'); + var rotateSecretResult = document.getElementById('rotateSecretResult'); + var newSecretKeyInput = document.getElementById('newSecretKey'); + var copyNewSecretBtn = document.getElementById('copyNewSecret'); + + document.querySelectorAll('[data-rotate-user]').forEach(function(btn) { + btn.addEventListener('click', function() { + 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(); + }); + }); + + if (confirmRotateBtn) { + confirmRotateBtn.addEventListener('click', async function() { + if (!currentRotateKey) return; + + window.UICore.setButtonLoading(confirmRotateBtn, true, 'Rotating...'); + + try { + var url = endpoints.rotateSecret.replace('ACCESS_KEY', currentRotateKey); + var response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'X-CSRFToken': csrfToken + } + }); + + if (!response.ok) { + var data = await response.json(); + throw new Error(data.error || 'Failed to rotate secret'); + } + + var 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 { + window.UICore.setButtonLoading(confirmRotateBtn, false); + } + }); + } + + if (copyNewSecretBtn) { + copyNewSecretBtn.addEventListener('click', async function() { + await window.UICore.copyToClipboard(newSecretKeyInput.value, copyNewSecretBtn, 'Copy'); + }); + } + + if (rotateDoneBtn) { + rotateDoneBtn.addEventListener('click', function() { + window.location.reload(); + }); + } + } + + function createUserCardHtml(accessKey, displayName, policies) { + var policyBadges = ''; + if (policies && policies.length > 0) { + policyBadges = policies.map(function(p) { + var actionText = p.actions && p.actions.includes('*') ? 'full' : (p.actions ? p.actions.length : 0); + return '' + + '' + + '' + + '' + window.UICore.escapeHtml(p.bucket) + + '(' + actionText + ')'; + }).join(''); + } else { + policyBadges = 'No policies'; + } + + return '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + window.UICore.escapeHtml(displayName) + '
' + + '' + window.UICore.escapeHtml(accessKey) + '' + + '
' + + '
' + + '
' + + '
Bucket Permissions
' + + '
' + policyBadges + '
' + + '' + + '
'; + } + + function attachUserCardHandlers(cardElement, accessKey, displayName) { + var editBtn = cardElement.querySelector('[data-edit-user]'); + if (editBtn) { + editBtn.addEventListener('click', function() { + currentEditKey = accessKey; + document.getElementById('editUserDisplayName').value = displayName; + document.getElementById('editUserForm').action = endpoints.updateUser.replace('ACCESS_KEY', accessKey); + editUserModal.show(); + }); + } + + var deleteBtn = cardElement.querySelector('[data-delete-user]'); + if (deleteBtn) { + deleteBtn.addEventListener('click', function() { + currentDeleteKey = accessKey; + document.getElementById('deleteUserLabel').textContent = accessKey; + document.getElementById('deleteUserForm').action = endpoints.deleteUser.replace('ACCESS_KEY', accessKey); + var deleteSelfWarning = document.getElementById('deleteSelfWarning'); + if (accessKey === currentUserKey) { + deleteSelfWarning.classList.remove('d-none'); + } else { + deleteSelfWarning.classList.add('d-none'); + } + deleteUserModal.show(); + }); + } + + var rotateBtn = cardElement.querySelector('[data-rotate-user]'); + if (rotateBtn) { + rotateBtn.addEventListener('click', function() { + currentRotateKey = accessKey; + document.getElementById('rotateUserLabel').textContent = accessKey; + document.getElementById('rotateSecretConfirm').classList.remove('d-none'); + document.getElementById('rotateSecretResult').classList.add('d-none'); + document.getElementById('confirmRotateBtn').classList.remove('d-none'); + document.getElementById('rotateCancelBtn').classList.remove('d-none'); + document.getElementById('rotateDoneBtn').classList.add('d-none'); + rotateSecretModal.show(); + }); + } + + var policyBtn = cardElement.querySelector('[data-policy-editor]'); + if (policyBtn) { + policyBtn.addEventListener('click', function() { + document.getElementById('policyEditorUserLabel').textContent = accessKey; + document.getElementById('policyEditorUser').value = accessKey; + document.getElementById('policyEditorDocument').value = getUserPolicies(accessKey); + policyModal.show(); + }); + } + } + + function updateUserCount() { + var countEl = document.querySelector('.card-header .text-muted.small'); + if (countEl) { + var count = document.querySelectorAll('.iam-user-card').length; + countEl.textContent = count + ' user' + (count !== 1 ? 's' : '') + ' configured'; + } + } + + function setupFormHandlers() { + var createUserForm = document.querySelector('#createUserModal form'); + if (createUserForm) { + createUserForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(createUserForm, { + successMessage: 'User created', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal')); + if (modal) modal.hide(); + createUserForm.reset(); + + var existingAlert = document.querySelector('.alert.alert-info.border-0.shadow-sm'); + if (existingAlert) existingAlert.remove(); + + if (data.secret_key) { + var alertHtml = ''; + var container = document.querySelector('.page-header'); + if (container) { + container.insertAdjacentHTML('afterend', alertHtml); + document.getElementById('copyNewUserSecret').addEventListener('click', async function() { + await window.UICore.copyToClipboard(data.secret_key, this, 'Copy'); + }); + } + } + + var usersGrid = document.querySelector('.row.g-3'); + var emptyState = document.querySelector('.empty-state'); + if (emptyState) { + var emptyCol = emptyState.closest('.col-12'); + if (emptyCol) emptyCol.remove(); + if (!usersGrid) { + var cardBody = document.querySelector('.card-body.px-4.pb-4'); + if (cardBody) { + cardBody.innerHTML = '
'; + usersGrid = cardBody.querySelector('.row.g-3'); + } + } + } + + if (usersGrid) { + var cardHtml = createUserCardHtml(data.access_key, data.display_name, data.policies); + usersGrid.insertAdjacentHTML('beforeend', cardHtml); + var newCard = usersGrid.lastElementChild; + attachUserCardHandlers(newCard, data.access_key, data.display_name); + users.push({ + access_key: data.access_key, + display_name: data.display_name, + policies: data.policies || [] + }); + updateUserCount(); + } + } + }); + }); + } + + var policyEditorForm = document.getElementById('policyEditorForm'); + if (policyEditorForm) { + policyEditorForm.addEventListener('submit', function(e) { + e.preventDefault(); + var userInputEl = document.getElementById('policyEditorUser'); + var key = userInputEl.value; + if (!key) return; + + var template = policyEditorForm.dataset.actionTemplate; + policyEditorForm.action = template.replace('ACCESS_KEY_PLACEHOLDER', key); + + window.UICore.submitFormAjax(policyEditorForm, { + successMessage: 'Policies updated', + onSuccess: function(data) { + policyModal.hide(); + + var userCard = document.querySelector('[data-access-key="' + key + '"]'); + if (userCard) { + var badgeContainer = userCard.closest('.iam-user-card').querySelector('.d-flex.flex-wrap.gap-1'); + if (badgeContainer && data.policies) { + var badges = data.policies.map(function(p) { + return '' + + '' + + '' + + '' + window.UICore.escapeHtml(p.bucket) + + '(' + (p.actions.includes('*') ? 'full' : p.actions.length) + ')'; + }).join(''); + badgeContainer.innerHTML = badges || 'No policies'; + } + } + + var userIndex = users.findIndex(function(u) { return u.access_key === key; }); + if (userIndex >= 0 && data.policies) { + users[userIndex].policies = data.policies; + } + } + }); + }); + } + + var editUserForm = document.getElementById('editUserForm'); + if (editUserForm) { + editUserForm.addEventListener('submit', function(e) { + e.preventDefault(); + var key = currentEditKey; + window.UICore.submitFormAjax(editUserForm, { + successMessage: 'User updated', + onSuccess: function(data) { + editUserModal.hide(); + + var newName = data.display_name || document.getElementById('editUserDisplayName').value; + var editBtn = document.querySelector('[data-edit-user="' + key + '"]'); + if (editBtn) { + editBtn.setAttribute('data-display-name', newName); + var card = editBtn.closest('.iam-user-card'); + if (card) { + var nameEl = card.querySelector('h6'); + if (nameEl) { + nameEl.textContent = newName; + nameEl.title = newName; + } + } + } + + var userIndex = users.findIndex(function(u) { return u.access_key === key; }); + if (userIndex >= 0) { + users[userIndex].display_name = newName; + } + + if (key === currentUserKey) { + document.querySelectorAll('.sidebar-user .user-name').forEach(function(el) { + var truncated = newName.length > 16 ? newName.substring(0, 16) + '...' : newName; + el.textContent = truncated; + el.title = newName; + }); + document.querySelectorAll('.sidebar-user[data-username]').forEach(function(el) { + el.setAttribute('data-username', newName); + }); + } + } + }); + }); + } + + var deleteUserForm = document.getElementById('deleteUserForm'); + if (deleteUserForm) { + deleteUserForm.addEventListener('submit', function(e) { + e.preventDefault(); + var key = currentDeleteKey; + window.UICore.submitFormAjax(deleteUserForm, { + successMessage: 'User deleted', + onSuccess: function(data) { + deleteUserModal.hide(); + + if (key === currentUserKey) { + window.location.href = '/ui/'; + return; + } + + var deleteBtn = document.querySelector('[data-delete-user="' + key + '"]'); + if (deleteBtn) { + var cardCol = deleteBtn.closest('[class*="col-"]'); + if (cardCol) { + cardCol.remove(); + } + } + + users = users.filter(function(u) { return u.access_key !== key; }); + updateUserCount(); + } + }); + }); + } + } + + return { + init: init + }; +})(); diff --git a/static/js/ui-core.js b/static/js/ui-core.js new file mode 100644 index 0000000..7f0a728 --- /dev/null +++ b/static/js/ui-core.js @@ -0,0 +1,311 @@ +window.UICore = (function() { + 'use strict'; + + function getCsrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; + } + + function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return bytes + ' bytes'; + const units = ['bytes', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; + } + + function escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + async function submitFormAjax(form, options) { + options = options || {}; + var onSuccess = options.onSuccess || function() {}; + var onError = options.onError || function() {}; + var successMessage = options.successMessage || 'Operation completed'; + + var formData = new FormData(form); + var csrfToken = getCsrfToken(); + var submitBtn = form.querySelector('[type="submit"]'); + var originalHtml = submitBtn ? submitBtn.innerHTML : ''; + + try { + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Saving...'; + } + + var formAction = form.getAttribute('action') || form.action; + var response = await fetch(formAction, { + method: form.getAttribute('method') || 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData, + redirect: 'follow' + }); + + var contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Server returned an unexpected response. Please try again.'); + } + + var data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'HTTP ' + response.status); + } + + window.showToast(data.message || successMessage, 'Success', 'success'); + onSuccess(data); + + } catch (err) { + window.showToast(err.message, 'Error', 'error'); + onError(err); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = originalHtml; + } + } + } + + function PollingManager() { + this.intervals = {}; + this.callbacks = {}; + this.timers = {}; + this.defaults = { + replication: 30000, + lifecycle: 60000, + connectionHealth: 60000, + bucketStats: 120000 + }; + this._loadSettings(); + } + + PollingManager.prototype._loadSettings = function() { + try { + var stored = localStorage.getItem('myfsio-polling-intervals'); + if (stored) { + var settings = JSON.parse(stored); + for (var key in settings) { + if (settings.hasOwnProperty(key)) { + this.defaults[key] = settings[key]; + } + } + } + } catch (e) { + console.warn('Failed to load polling settings:', e); + } + }; + + PollingManager.prototype.saveSettings = function(settings) { + try { + for (var key in settings) { + if (settings.hasOwnProperty(key)) { + this.defaults[key] = settings[key]; + } + } + localStorage.setItem('myfsio-polling-intervals', JSON.stringify(this.defaults)); + } catch (e) { + console.warn('Failed to save polling settings:', e); + } + }; + + PollingManager.prototype.start = function(key, callback, interval) { + this.stop(key); + var ms = interval !== undefined ? interval : (this.defaults[key] || 30000); + if (ms <= 0) return; + + this.callbacks[key] = callback; + this.intervals[key] = ms; + + callback(); + + var self = this; + this.timers[key] = setInterval(function() { + if (!document.hidden) { + callback(); + } + }, ms); + }; + + PollingManager.prototype.stop = function(key) { + if (this.timers[key]) { + clearInterval(this.timers[key]); + delete this.timers[key]; + } + }; + + PollingManager.prototype.stopAll = function() { + for (var key in this.timers) { + if (this.timers.hasOwnProperty(key)) { + clearInterval(this.timers[key]); + } + } + this.timers = {}; + }; + + PollingManager.prototype.updateInterval = function(key, newInterval) { + var callback = this.callbacks[key]; + this.defaults[key] = newInterval; + this.saveSettings(this.defaults); + if (callback) { + this.start(key, callback, newInterval); + } + }; + + PollingManager.prototype.getSettings = function() { + var result = {}; + for (var key in this.defaults) { + if (this.defaults.hasOwnProperty(key)) { + result[key] = this.defaults[key]; + } + } + return result; + }; + + var pollingManager = new PollingManager(); + + document.addEventListener('visibilitychange', function() { + if (document.hidden) { + pollingManager.stopAll(); + } else { + for (var key in pollingManager.callbacks) { + if (pollingManager.callbacks.hasOwnProperty(key)) { + pollingManager.start(key, pollingManager.callbacks[key], pollingManager.intervals[key]); + } + } + } + }); + + return { + getCsrfToken: getCsrfToken, + formatBytes: formatBytes, + escapeHtml: escapeHtml, + submitFormAjax: submitFormAjax, + PollingManager: PollingManager, + pollingManager: pollingManager + }; +})(); + +window.pollingManager = window.UICore.pollingManager; + +window.UICore.copyToClipboard = async function(text, button, originalText) { + try { + await navigator.clipboard.writeText(text); + if (button) { + var prevText = button.textContent; + button.textContent = 'Copied!'; + setTimeout(function() { + button.textContent = originalText || prevText; + }, 1500); + } + return true; + } catch (err) { + console.error('Copy failed:', err); + return false; + } +}; + +window.UICore.setButtonLoading = function(button, isLoading, loadingText) { + if (!button) return; + if (isLoading) { + button._originalHtml = button.innerHTML; + button._originalDisabled = button.disabled; + button.disabled = true; + button.innerHTML = '' + (loadingText || 'Loading...'); + } else { + button.disabled = button._originalDisabled || false; + button.innerHTML = button._originalHtml || button.innerHTML; + } +}; + +window.UICore.updateBadgeCount = function(selector, count, singular, plural) { + var badge = document.querySelector(selector); + if (badge) { + var label = count === 1 ? (singular || '') : (plural || 's'); + badge.textContent = count + ' ' + label; + } +}; + +window.UICore.setupJsonAutoIndent = function(textarea) { + if (!textarea) return; + + textarea.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + + var start = this.selectionStart; + var end = this.selectionEnd; + var value = this.value; + + var lineStart = value.lastIndexOf('\n', start - 1) + 1; + var currentLine = value.substring(lineStart, start); + + var indentMatch = currentLine.match(/^(\s*)/); + var indent = indentMatch ? indentMatch[1] : ''; + + var trimmedLine = currentLine.trim(); + var lastChar = trimmedLine.slice(-1); + + var newIndent = indent; + var insertAfter = ''; + + if (lastChar === '{' || lastChar === '[') { + newIndent = indent + ' '; + + var charAfterCursor = value.substring(start, start + 1).trim(); + if ((lastChar === '{' && charAfterCursor === '}') || + (lastChar === '[' && charAfterCursor === ']')) { + insertAfter = '\n' + indent; + } + } else if (lastChar === ',' || lastChar === ':') { + newIndent = indent; + } + + var insertion = '\n' + newIndent + insertAfter; + var newValue = value.substring(0, start) + insertion + value.substring(end); + + this.value = newValue; + + var newCursorPos = start + 1 + newIndent.length; + this.selectionStart = this.selectionEnd = newCursorPos; + + this.dispatchEvent(new Event('input', { bubbles: true })); + } + + if (e.key === 'Tab') { + e.preventDefault(); + var start = this.selectionStart; + var end = this.selectionEnd; + + if (e.shiftKey) { + var lineStart = this.value.lastIndexOf('\n', start - 1) + 1; + var 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 })); + } + }); +}; diff --git a/templates/base.html b/templates/base.html index c3363d1..146acc7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -393,6 +393,8 @@ {% endwith %} })(); + {% block extra_scripts %}{% endblock %} + diff --git a/templates/bucket_detail.html b/templates/bucket_detail.html index b4cdb36..f1ed7ba 100644 --- a/templates/bucket_detail.html +++ b/templates/bucket_detail.html @@ -473,15 +473,13 @@ Save Policy - {% if bucket_policy %} - - {% endif %} {% else %} @@ -636,7 +634,7 @@ Suspend Versioning {% else %} -
+ - {% if has_quota %} - - {% endif %}
{% else %} @@ -1856,7 +1853,7 @@ - +
- + - Live + Auto-refresh: 5s -
-

{{ cpu_percent }}%

+

{{ cpu_percent }}%

-
+
Current load - - {% if cpu_percent > 80 %}High{% elif cpu_percent > 50 %}Medium{% else %}Normal{% endif %} - + Normal
@@ -57,13 +55,13 @@ -

{{ memory.percent }}%

+

{{ memory.percent }}%

-
+
- {{ memory.used }} used - {{ memory.total }} total + {{ memory.used }} used + {{ memory.total }} total
@@ -81,13 +79,13 @@ -

{{ disk.percent }}%

+

{{ disk.percent }}%

-
+
- {{ disk.free }} free - {{ disk.total }} total + {{ disk.free }} free + {{ disk.total }} total
@@ -104,15 +102,15 @@ -

{{ app.storage_used }}

+

{{ app.storage_used }}

-
{{ app.buckets }}
+
{{ app.buckets }}
Buckets
-
{{ app.objects }}
+
{{ app.objects }}
Objects
@@ -270,3 +268,109 @@ {% endblock %} + +{% block extra_scripts %} + +{% endblock %}