Implement dynamic UI loading
This commit is contained in:
314
app/ui.py
314
app/ui.py
@@ -102,6 +102,12 @@ def _friendly_error_message(exc: Exception) -> str:
|
|||||||
return message
|
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:
|
def _policy_allows_public_read(policy: dict[str, Any]) -> bool:
|
||||||
statements = policy.get("Statement", [])
|
statements = policy.get("Statement", [])
|
||||||
if isinstance(statements, dict):
|
if isinstance(statements, dict):
|
||||||
@@ -285,13 +291,19 @@ def create_bucket():
|
|||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
bucket_name = request.form.get("bucket_name", "").strip()
|
bucket_name = request.form.get("bucket_name", "").strip()
|
||||||
if not bucket_name:
|
if not bucket_name:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Bucket name is required"}), 400
|
||||||
flash("Bucket name is required", "danger")
|
flash("Bucket name is required", "danger")
|
||||||
return redirect(url_for("ui.buckets_overview"))
|
return redirect(url_for("ui.buckets_overview"))
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "write")
|
_authorize_ui(principal, bucket_name, "write")
|
||||||
_storage().create_bucket(bucket_name)
|
_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")
|
flash(f"Bucket '{bucket_name}' created", "success")
|
||||||
except (StorageError, FileExistsError, IamError) as exc:
|
except (StorageError, FileExistsError, IamError) as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.buckets_overview"))
|
return redirect(url_for("ui.buckets_overview"))
|
||||||
|
|
||||||
@@ -649,8 +661,12 @@ def delete_bucket(bucket_name: str):
|
|||||||
_storage().delete_bucket(bucket_name)
|
_storage().delete_bucket(bucket_name)
|
||||||
_bucket_policies().delete_policy(bucket_name)
|
_bucket_policies().delete_policy(bucket_name)
|
||||||
_replication_manager().delete_rule(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")
|
flash(f"Bucket '{bucket_name}' removed", "success")
|
||||||
except (StorageError, IamError) as exc:
|
except (StorageError, IamError) as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.buckets_overview"))
|
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)
|
_authorize_ui(principal, bucket_name, "delete", object_key=object_key)
|
||||||
if purge_versions:
|
if purge_versions:
|
||||||
_storage().purge_object(bucket_name, object_key)
|
_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:
|
else:
|
||||||
_storage().delete_object(bucket_name, object_key)
|
_storage().delete_object(bucket_name, object_key)
|
||||||
_replication_manager().trigger_replication(bucket_name, object_key, action="delete")
|
_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:
|
except (IamError, StorageError) as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name))
|
||||||
|
|
||||||
@@ -979,22 +1000,32 @@ def update_bucket_policy(bucket_name: str):
|
|||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "policy")
|
_authorize_ui(principal, bucket_name, "policy")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name))
|
||||||
store = _bucket_policies()
|
store = _bucket_policies()
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
store.delete_policy(bucket_name)
|
store.delete_policy(bucket_name)
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"success": True, "message": "Bucket policy removed"})
|
||||||
flash("Bucket policy removed", "info")
|
flash("Bucket policy removed", "info")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions"))
|
||||||
document = request.form.get("policy_document", "").strip()
|
document = request.form.get("policy_document", "").strip()
|
||||||
if not document:
|
if not document:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Provide a JSON policy document"}), 400
|
||||||
flash("Provide a JSON policy document", "danger")
|
flash("Provide a JSON policy document", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions"))
|
||||||
try:
|
try:
|
||||||
payload = json.loads(document)
|
payload = json.loads(document)
|
||||||
store.set_policy(bucket_name, payload)
|
store.set_policy(bucket_name, payload)
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"success": True, "message": "Bucket policy saved"})
|
||||||
flash("Bucket policy saved", "success")
|
flash("Bucket policy saved", "success")
|
||||||
except (json.JSONDecodeError, ValueError) as exc:
|
except (json.JSONDecodeError, ValueError) as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": f"Policy error: {exc}"}), 400
|
||||||
flash(f"Policy error: {exc}", "danger")
|
flash(f"Policy error: {exc}", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions"))
|
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:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "write")
|
_authorize_ui(principal, bucket_name, "write")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 403
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
state = request.form.get("state", "enable")
|
state = request.form.get("state", "enable")
|
||||||
@@ -1012,9 +1045,14 @@ def update_bucket_versioning(bucket_name: str):
|
|||||||
try:
|
try:
|
||||||
_storage().set_bucket_versioning(bucket_name, enable)
|
_storage().set_bucket_versioning(bucket_name, enable)
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
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"))
|
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):
|
def update_bucket_quota(bucket_name: str):
|
||||||
"""Update bucket quota configuration (admin only)."""
|
"""Update bucket quota configuration (admin only)."""
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
|
|
||||||
is_admin = False
|
is_admin = False
|
||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:list_users")
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
is_admin = True
|
is_admin = True
|
||||||
except IamError:
|
except IamError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not is_admin:
|
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")
|
flash("Only administrators can manage bucket quotas", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
action = request.form.get("action", "set")
|
action = request.form.get("action", "set")
|
||||||
|
|
||||||
if action == "remove":
|
if action == "remove":
|
||||||
try:
|
try:
|
||||||
_storage().set_bucket_quota(bucket_name, max_bytes=None, max_objects=None)
|
_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")
|
flash("Bucket quota removed", "info")
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
max_mb_str = request.form.get("max_mb", "").strip()
|
max_mb_str = request.form.get("max_mb", "").strip()
|
||||||
max_objects_str = request.form.get("max_objects", "").strip()
|
max_objects_str = request.form.get("max_objects", "").strip()
|
||||||
|
|
||||||
max_bytes = None
|
max_bytes = None
|
||||||
max_objects = None
|
max_objects = None
|
||||||
|
|
||||||
if max_mb_str:
|
if max_mb_str:
|
||||||
try:
|
try:
|
||||||
max_mb = int(max_mb_str)
|
max_mb = int(max_mb_str)
|
||||||
if max_mb < 1:
|
if max_mb < 1:
|
||||||
raise ValueError("Size must be at least 1 MB")
|
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:
|
except ValueError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": f"Invalid size value: {exc}"}), 400
|
||||||
flash(f"Invalid size value: {exc}", "danger")
|
flash(f"Invalid size value: {exc}", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
if max_objects_str:
|
if max_objects_str:
|
||||||
try:
|
try:
|
||||||
max_objects = int(max_objects_str)
|
max_objects = int(max_objects_str)
|
||||||
if max_objects < 0:
|
if max_objects < 0:
|
||||||
raise ValueError("Object count must be non-negative")
|
raise ValueError("Object count must be non-negative")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": f"Invalid object count: {exc}"}), 400
|
||||||
flash(f"Invalid object count: {exc}", "danger")
|
flash(f"Invalid object count: {exc}", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_storage().set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
_storage().set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
||||||
if max_bytes is None and max_objects is None:
|
if max_bytes is None and max_objects is None:
|
||||||
flash("Bucket quota removed", "info")
|
message = "Bucket quota removed"
|
||||||
else:
|
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:
|
except StorageError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
|
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
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:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "write")
|
_authorize_ui(principal, bucket_name, "write")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 403
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
action = request.form.get("action", "enable")
|
action = request.form.get("action", "enable")
|
||||||
|
|
||||||
if action == "disable":
|
if action == "disable":
|
||||||
try:
|
try:
|
||||||
_storage().set_bucket_encryption(bucket_name, None)
|
_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")
|
flash("Default encryption disabled", "info")
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
algorithm = request.form.get("algorithm", "AES256")
|
algorithm = request.form.get("algorithm", "AES256")
|
||||||
kms_key_id = request.form.get("kms_key_id", "").strip() or None
|
kms_key_id = request.form.get("kms_key_id", "").strip() or None
|
||||||
|
|
||||||
if algorithm not in ("AES256", "aws:kms"):
|
if algorithm not in ("AES256", "aws:kms"):
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Invalid encryption algorithm"}), 400
|
||||||
flash("Invalid encryption algorithm", "danger")
|
flash("Invalid encryption algorithm", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
encryption_config: dict[str, Any] = {
|
encryption_config: dict[str, Any] = {
|
||||||
"Rules": [
|
"Rules": [
|
||||||
{
|
{
|
||||||
@@ -1117,19 +1184,24 @@ def update_bucket_encryption(bucket_name: str):
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if algorithm == "aws:kms" and kms_key_id:
|
if algorithm == "aws:kms" and kms_key_id:
|
||||||
encryption_config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["KMSMasterKeyID"] = kms_key_id
|
encryption_config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["KMSMasterKeyID"] = kms_key_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_storage().set_bucket_encryption(bucket_name, encryption_config)
|
_storage().set_bucket_encryption(bucket_name, encryption_config)
|
||||||
if algorithm == "aws:kms":
|
if algorithm == "aws:kms":
|
||||||
flash("Default KMS encryption enabled", "success")
|
message = "Default KMS encryption enabled"
|
||||||
else:
|
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:
|
except StorageError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": _friendly_error_message(exc)}), 400
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
|
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
|
||||||
@@ -1178,10 +1250,14 @@ def create_iam_user():
|
|||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:create_user")
|
_iam().authorize(principal, None, "iam:create_user")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
display_name = request.form.get("display_name", "").strip() or "Unnamed"
|
display_name = request.form.get("display_name", "").strip() or "Unnamed"
|
||||||
if len(display_name) > 64:
|
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")
|
flash("Display name must be 64 characters or fewer", "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
policies_text = request.form.get("policies", "").strip()
|
policies_text = request.form.get("policies", "").strip()
|
||||||
@@ -1190,11 +1266,15 @@ def create_iam_user():
|
|||||||
try:
|
try:
|
||||||
policies = json.loads(policies_text)
|
policies = json.loads(policies_text)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": f"Invalid JSON: {exc}"}), 400
|
||||||
flash(f"Invalid JSON: {exc}", "danger")
|
flash(f"Invalid JSON: {exc}", "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
try:
|
try:
|
||||||
created = _iam().create_user(display_name=display_name, policies=policies)
|
created = _iam().create_user(display_name=display_name, policies=policies)
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
@@ -1205,6 +1285,15 @@ def create_iam_user():
|
|||||||
"operation": "create",
|
"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")
|
flash(f"Created user {created['access_key']}. Copy the secret below.", "success")
|
||||||
return redirect(url_for("ui.iam_dashboard", secret_token=token))
|
return redirect(url_for("ui.iam_dashboard", secret_token=token))
|
||||||
|
|
||||||
@@ -1256,18 +1345,26 @@ def update_iam_user(access_key: str):
|
|||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:create_user")
|
_iam().authorize(principal, None, "iam:create_user")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
display_name = request.form.get("display_name", "").strip()
|
display_name = request.form.get("display_name", "").strip()
|
||||||
if display_name:
|
if display_name:
|
||||||
if len(display_name) > 64:
|
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")
|
flash("Display name must be 64 characters or fewer", "danger")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
_iam().update_user(access_key, display_name)
|
_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")
|
flash(f"Updated user {access_key}", "success")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
|
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
@@ -1279,6 +1376,8 @@ def delete_iam_user(access_key: str):
|
|||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:delete_user")
|
_iam().authorize(principal, None, "iam:delete_user")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
@@ -1286,16 +1385,24 @@ def delete_iam_user(access_key: str):
|
|||||||
try:
|
try:
|
||||||
_iam().delete_user(access_key)
|
_iam().delete_user(access_key)
|
||||||
session.pop("credentials", None)
|
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")
|
flash("Your account has been deleted.", "info")
|
||||||
return redirect(url_for("ui.login"))
|
return redirect(url_for("ui.login"))
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_iam().delete_user(access_key)
|
_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")
|
flash(f"Deleted user {access_key}", "success")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
@@ -1306,6 +1413,8 @@ def update_iam_policies(access_key: str):
|
|||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:update_policy")
|
_iam().authorize(principal, None, "iam:update_policy")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
@@ -1318,13 +1427,19 @@ def update_iam_policies(access_key: str):
|
|||||||
if not isinstance(policies, list):
|
if not isinstance(policies, list):
|
||||||
raise ValueError("Policies must be a list")
|
raise ValueError("Policies must be a list")
|
||||||
except (ValueError, json.JSONDecodeError):
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Invalid JSON format for policies"}), 400
|
||||||
flash("Invalid JSON format for policies", "danger")
|
flash("Invalid JSON format for policies", "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_iam().update_user_policies(access_key, policies)
|
_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")
|
flash(f"Updated policies for {access_key}", "success")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
|
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
@@ -1336,19 +1451,23 @@ def create_connection():
|
|||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:list_users")
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
except IamError:
|
except IamError:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
flash("Access denied", "danger")
|
flash("Access denied", "danger")
|
||||||
return redirect(url_for("ui.buckets_overview"))
|
return redirect(url_for("ui.buckets_overview"))
|
||||||
|
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
endpoint = request.form.get("endpoint_url", "").strip()
|
endpoint = request.form.get("endpoint_url", "").strip()
|
||||||
access_key = request.form.get("access_key", "").strip()
|
access_key = request.form.get("access_key", "").strip()
|
||||||
secret_key = request.form.get("secret_key", "").strip()
|
secret_key = request.form.get("secret_key", "").strip()
|
||||||
region = request.form.get("region", "us-east-1").strip()
|
region = request.form.get("region", "us-east-1").strip()
|
||||||
|
|
||||||
if not all([name, endpoint, access_key, secret_key]):
|
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")
|
flash("All fields are required", "danger")
|
||||||
return redirect(url_for("ui.connections_dashboard"))
|
return redirect(url_for("ui.connections_dashboard"))
|
||||||
|
|
||||||
conn = RemoteConnection(
|
conn = RemoteConnection(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=name,
|
name=name,
|
||||||
@@ -1358,6 +1477,8 @@ def create_connection():
|
|||||||
region=region
|
region=region
|
||||||
)
|
)
|
||||||
_connections().add(conn)
|
_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")
|
flash(f"Connection '{name}' created", "success")
|
||||||
return redirect(url_for("ui.connections_dashboard"))
|
return redirect(url_for("ui.connections_dashboard"))
|
||||||
|
|
||||||
@@ -1417,11 +1538,15 @@ def update_connection(connection_id: str):
|
|||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:list_users")
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
except IamError:
|
except IamError:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
flash("Access denied", "danger")
|
flash("Access denied", "danger")
|
||||||
return redirect(url_for("ui.buckets_overview"))
|
return redirect(url_for("ui.buckets_overview"))
|
||||||
|
|
||||||
conn = _connections().get(connection_id)
|
conn = _connections().get(connection_id)
|
||||||
if not conn:
|
if not conn:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Connection not found"}), 404
|
||||||
flash("Connection not found", "danger")
|
flash("Connection not found", "danger")
|
||||||
return redirect(url_for("ui.connections_dashboard"))
|
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()
|
region = request.form.get("region", "us-east-1").strip()
|
||||||
|
|
||||||
if not all([name, endpoint, access_key, secret_key]):
|
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")
|
flash("All fields are required", "danger")
|
||||||
return redirect(url_for("ui.connections_dashboard"))
|
return redirect(url_for("ui.connections_dashboard"))
|
||||||
|
|
||||||
@@ -1440,8 +1567,20 @@ def update_connection(connection_id: str):
|
|||||||
conn.access_key = access_key
|
conn.access_key = access_key
|
||||||
conn.secret_key = secret_key
|
conn.secret_key = secret_key
|
||||||
conn.region = region
|
conn.region = region
|
||||||
|
|
||||||
_connections().save()
|
_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")
|
flash(f"Connection '{name}' updated", "success")
|
||||||
return redirect(url_for("ui.connections_dashboard"))
|
return redirect(url_for("ui.connections_dashboard"))
|
||||||
|
|
||||||
@@ -1452,10 +1591,14 @@ def delete_connection(connection_id: str):
|
|||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:list_users")
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
except IamError:
|
except IamError:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
flash("Access denied", "danger")
|
flash("Access denied", "danger")
|
||||||
return redirect(url_for("ui.buckets_overview"))
|
return redirect(url_for("ui.buckets_overview"))
|
||||||
|
|
||||||
_connections().delete(connection_id)
|
_connections().delete(connection_id)
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"success": True, "message": "Connection deleted"})
|
||||||
flash("Connection deleted", "success")
|
flash("Connection deleted", "success")
|
||||||
return redirect(url_for("ui.connections_dashboard"))
|
return redirect(url_for("ui.connections_dashboard"))
|
||||||
|
|
||||||
@@ -1466,31 +1609,41 @@ def update_bucket_replication(bucket_name: str):
|
|||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "replication")
|
_authorize_ui(principal, bucket_name, "replication")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||||
|
|
||||||
is_admin = False
|
is_admin = False
|
||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:list_users")
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
is_admin = True
|
is_admin = True
|
||||||
except IamError:
|
except IamError:
|
||||||
is_admin = False
|
is_admin = False
|
||||||
|
|
||||||
action = request.form.get("action")
|
action = request.form.get("action")
|
||||||
|
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
if not is_admin:
|
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")
|
flash("Only administrators can remove replication configuration", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||||
_replication().delete_rule(bucket_name)
|
_replication().delete_rule(bucket_name)
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"success": True, "message": "Replication configuration removed", "action": "delete"})
|
||||||
flash("Replication configuration removed", "info")
|
flash("Replication configuration removed", "info")
|
||||||
elif action == "pause":
|
elif action == "pause":
|
||||||
rule = _replication().get_rule(bucket_name)
|
rule = _replication().get_rule(bucket_name)
|
||||||
if rule:
|
if rule:
|
||||||
rule.enabled = False
|
rule.enabled = False
|
||||||
_replication().set_rule(rule)
|
_replication().set_rule(rule)
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"success": True, "message": "Replication paused", "action": "pause", "enabled": False})
|
||||||
flash("Replication paused", "info")
|
flash("Replication paused", "info")
|
||||||
else:
|
else:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "No replication configuration to pause"}), 404
|
||||||
flash("No replication configuration to pause", "warning")
|
flash("No replication configuration to pause", "warning")
|
||||||
elif action == "resume":
|
elif action == "resume":
|
||||||
from .replication import REPLICATION_MODE_ALL
|
from .replication import REPLICATION_MODE_ALL
|
||||||
@@ -1500,24 +1653,33 @@ def update_bucket_replication(bucket_name: str):
|
|||||||
_replication().set_rule(rule)
|
_replication().set_rule(rule)
|
||||||
if rule.mode == REPLICATION_MODE_ALL:
|
if rule.mode == REPLICATION_MODE_ALL:
|
||||||
_replication().replicate_existing_objects(bucket_name)
|
_replication().replicate_existing_objects(bucket_name)
|
||||||
flash("Replication resumed. Syncing pending objects in background.", "success")
|
message = "Replication resumed. Syncing pending objects in background."
|
||||||
else:
|
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:
|
else:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "No replication configuration to resume"}), 404
|
||||||
flash("No replication configuration to resume", "warning")
|
flash("No replication configuration to resume", "warning")
|
||||||
elif action == "create":
|
elif action == "create":
|
||||||
if not is_admin:
|
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")
|
flash("Only administrators can configure replication settings", "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||||
|
|
||||||
from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL
|
from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL
|
||||||
import time
|
import time
|
||||||
|
|
||||||
target_conn_id = request.form.get("target_connection_id")
|
target_conn_id = request.form.get("target_connection_id")
|
||||||
target_bucket = request.form.get("target_bucket", "").strip()
|
target_bucket = request.form.get("target_bucket", "").strip()
|
||||||
replication_mode = request.form.get("replication_mode", REPLICATION_MODE_NEW_ONLY)
|
replication_mode = request.form.get("replication_mode", REPLICATION_MODE_NEW_ONLY)
|
||||||
|
|
||||||
if not target_conn_id or not target_bucket:
|
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")
|
flash("Target connection and bucket are required", "danger")
|
||||||
else:
|
else:
|
||||||
rule = ReplicationRule(
|
rule = ReplicationRule(
|
||||||
@@ -1529,15 +1691,20 @@ def update_bucket_replication(bucket_name: str):
|
|||||||
created_at=time.time(),
|
created_at=time.time(),
|
||||||
)
|
)
|
||||||
_replication().set_rule(rule)
|
_replication().set_rule(rule)
|
||||||
|
|
||||||
if replication_mode == REPLICATION_MODE_ALL:
|
if replication_mode == REPLICATION_MODE_ALL:
|
||||||
_replication().replicate_existing_objects(bucket_name)
|
_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:
|
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:
|
else:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Invalid action"}), 400
|
||||||
flash("Invalid action", "danger")
|
flash("Invalid action", "danger")
|
||||||
|
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
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/<bucket_name>/lifecycle", methods=["GET", "POST", "DELETE"])
|
@ui_bp.route("/buckets/<bucket_name>/lifecycle", methods=["GET", "POST", "DELETE"])
|
||||||
def bucket_lifecycle(bucket_name: str):
|
def bucket_lifecycle(bucket_name: str):
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
|
|||||||
@@ -525,7 +525,7 @@
|
|||||||
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
||||||
const deleteObjectKey = document.getElementById('deleteObjectKey');
|
const deleteObjectKey = document.getElementById('deleteObjectKey');
|
||||||
if (deleteModal && deleteObjectForm) {
|
if (deleteModal && deleteObjectForm) {
|
||||||
deleteObjectForm.action = row.dataset.deleteEndpoint;
|
deleteObjectForm.setAttribute('action', row.dataset.deleteEndpoint);
|
||||||
if (deleteObjectKey) deleteObjectKey.textContent = row.dataset.key;
|
if (deleteObjectKey) deleteObjectKey.textContent = row.dataset.key;
|
||||||
deleteModal.show();
|
deleteModal.show();
|
||||||
}
|
}
|
||||||
@@ -866,6 +866,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => {
|
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) {
|
if (!messageModal) {
|
||||||
window.alert(body || title);
|
window.alert(body || title);
|
||||||
return;
|
return;
|
||||||
@@ -1147,7 +1151,11 @@
|
|||||||
}
|
}
|
||||||
const summary = messageParts.length ? messageParts.join(', ') : 'Bulk delete finished';
|
const summary = messageParts.length ? messageParts.join(', ') : 'Bulk delete finished';
|
||||||
showMessage({ title: 'Bulk delete complete', body: data.message || summary, variant: errorCount ? 'warning' : 'success' });
|
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) {
|
} catch (error) {
|
||||||
bulkDeleteModal?.hide();
|
bulkDeleteModal?.hide();
|
||||||
showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete selected objects', variant: 'danger' });
|
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' });
|
showMessage({ title: 'Restore scheduled', body: data.message || 'Object restored from archive.', variant: 'success' });
|
||||||
await loadArchivedObjects();
|
await loadArchivedObjects();
|
||||||
window.setTimeout(() => window.location.reload(), 600);
|
loadObjects(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore archived object', variant: 'danger' });
|
showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore archived object', variant: 'danger' });
|
||||||
}
|
}
|
||||||
@@ -1470,7 +1478,7 @@
|
|||||||
}
|
}
|
||||||
await loadObjectVersions(row, { force: true });
|
await loadObjectVersions(row, { force: true });
|
||||||
showMessage({ title: 'Version restored', body: data.message || 'The selected version has been restored.', variant: 'success' });
|
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) {
|
} catch (error) {
|
||||||
showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore version', variant: 'danger' });
|
showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore version', variant: 'danger' });
|
||||||
}
|
}
|
||||||
@@ -1563,6 +1571,54 @@
|
|||||||
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
||||||
const deleteObjectKey = document.getElementById('deleteObjectKey');
|
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 = '<span class="spinner-border spinner-border-sm me-1"></span>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 = () => {
|
const resetPreviewMedia = () => {
|
||||||
[previewImage, previewVideo, previewIframe].forEach((el) => {
|
[previewImage, previewVideo, previewIframe].forEach((el) => {
|
||||||
el.classList.add('d-none');
|
el.classList.add('d-none');
|
||||||
@@ -2234,6 +2290,7 @@
|
|||||||
const finishUploadSession = () => {
|
const finishUploadSession = () => {
|
||||||
if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
|
if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
|
||||||
if (bulkUploadResults) bulkUploadResults.classList.remove('d-none');
|
if (bulkUploadResults) bulkUploadResults.classList.remove('d-none');
|
||||||
|
hideFloatingProgress();
|
||||||
|
|
||||||
if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = uploadSuccessFiles.length;
|
if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = uploadSuccessFiles.length;
|
||||||
if (uploadSuccessFiles.length === 0 && bulkUploadSuccessAlert) {
|
if (uploadSuccessFiles.length === 0 && bulkUploadSuccessAlert) {
|
||||||
@@ -2256,13 +2313,22 @@
|
|||||||
updateUploadBtnText();
|
updateUploadBtnText();
|
||||||
updateQueueListDisplay();
|
updateQueueListDisplay();
|
||||||
|
|
||||||
if (uploadSuccessFiles.length > 0) {
|
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
|
||||||
if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...';
|
if (uploadFileInput) {
|
||||||
const objectsTabUrl = window.location.pathname + '?tab=objects';
|
uploadFileInput.disabled = false;
|
||||||
window.setTimeout(() => window.location.href = objectsTabUrl, 800);
|
uploadFileInput.value = '';
|
||||||
} else {
|
}
|
||||||
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
|
|
||||||
if (uploadFileInput) uploadFileInput.disabled = false;
|
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;
|
if (uploadSubmitBtn) uploadSubmitBtn.disabled = true;
|
||||||
refreshUploadDropLabel();
|
refreshUploadDropLabel();
|
||||||
updateUploadBtnText();
|
updateUploadBtnText();
|
||||||
|
|
||||||
|
if (uploadModal) uploadModal.hide();
|
||||||
|
showFloatingProgress();
|
||||||
|
showMessage({ title: 'Upload started', body: `Uploading ${files.length} file(s)...`, variant: 'info' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileCount = files.length;
|
const fileCount = files.length;
|
||||||
@@ -2610,6 +2680,10 @@
|
|||||||
|
|
||||||
loadReplicationStats();
|
loadReplicationStats();
|
||||||
|
|
||||||
|
if (window.pollingManager) {
|
||||||
|
window.pollingManager.start('replication', loadReplicationStats);
|
||||||
|
}
|
||||||
|
|
||||||
const refreshBtn = document.querySelector('[data-refresh-replication]');
|
const refreshBtn = document.querySelector('[data-refresh-replication]');
|
||||||
refreshBtn?.addEventListener('click', () => {
|
refreshBtn?.addEventListener('click', () => {
|
||||||
|
|
||||||
@@ -3407,7 +3481,12 @@
|
|||||||
if (!resp.ok) throw new Error(data.error || `Failed to ${copyMoveAction} object`);
|
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' });
|
showMessage({ title: `Object ${copyMoveAction === 'move' ? 'moved' : 'copied'}`, body: `Successfully ${copyMoveAction === 'move' ? 'moved' : 'copied'} to ${destBucket}/${destKey}`, variant: 'success' });
|
||||||
copyMoveModal?.hide();
|
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) {
|
} catch (err) {
|
||||||
showMessage({ title: `${copyMoveAction === 'move' ? 'Move' : 'Copy'} failed`, body: err.message, variant: 'danger' });
|
showMessage({ title: `${copyMoveAction === 'move' ? 'Move' : 'Copy'} failed`, body: err.message, variant: 'danger' });
|
||||||
}
|
}
|
||||||
@@ -3495,9 +3574,383 @@
|
|||||||
loadLifecycleHistory();
|
loadLifecycleHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (lifecycleHistoryCard) loadLifecycleHistory();
|
if (lifecycleHistoryCard) {
|
||||||
|
loadLifecycleHistory();
|
||||||
|
if (window.pollingManager) {
|
||||||
|
window.pollingManager.start('lifecycle', loadLifecycleHistory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (corsCard) loadCorsRules();
|
if (corsCard) loadCorsRules();
|
||||||
if (aclCard) loadAcl();
|
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zm.995-14.901a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z"/>' +
|
||||||
|
'</svg>';
|
||||||
|
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 = '<div class="alert alert-success d-flex align-items-start mb-4" role="alert">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" 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><div><strong>Versioning is enabled</strong>' +
|
||||||
|
'<p class="mb-0 small">All previous versions of objects are preserved. You can roll back accidental changes or deletions at any time.</p>' +
|
||||||
|
'</div></div>' +
|
||||||
|
'<button class="btn btn-outline-danger" type="button" data-bs-toggle="modal" data-bs-target="#suspendVersioningModal">' +
|
||||||
|
'<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 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>' +
|
||||||
|
'</svg>Suspend Versioning</button>';
|
||||||
|
|
||||||
|
var disabledHtml = '<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>' +
|
||||||
|
'<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><div><strong>Versioning is suspended</strong>' +
|
||||||
|
'<p class="mb-0 small">New object uploads overwrite existing objects. Enable versioning to preserve previous versions.</p>' +
|
||||||
|
'</div></div>' +
|
||||||
|
'<form method="post" id="enableVersioningForm">' +
|
||||||
|
'<input type="hidden" name="csrf_token" value="' + window.UICore.getCsrfToken() + '" />' +
|
||||||
|
'<input type="hidden" name="state" value="enable" />' +
|
||||||
|
'<button class="btn btn-success" type="submit">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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>Enable Versioning</button></form>';
|
||||||
|
|
||||||
|
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>' +
|
||||||
|
'<path d="M9.5 6.5a1.5 1.5 0 0 1-1 1.415l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99a1.5 1.5 0 1 1 2-1.415z"/>' +
|
||||||
|
'</svg><div><strong>Default encryption enabled (' + algoText + ')</strong>' +
|
||||||
|
'<p class="mb-0 small">All new objects uploaded to this bucket will be automatically encrypted.</p></div>';
|
||||||
|
} else {
|
||||||
|
alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4';
|
||||||
|
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M11 1a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3a3 3 0 0 1 6 0v4a.5.5 0 0 1-1 0V3a2 2 0 0 0-2-2zM3 8a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1H3z"/>' +
|
||||||
|
'</svg><div><strong>Default encryption disabled</strong>' +
|
||||||
|
'<p class="mb-0 small">Objects are stored without default encryption. You can enable server-side encryption below.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8 4a.905.905 0 0 0-.9.995l.35 3.507a.552.552 0 0 0 1.1 0l.35-3.507A.905.905 0 0 0 8 4zm.002 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/>' +
|
||||||
|
'</svg><div><strong>Storage quota active</strong>' +
|
||||||
|
'<p class="mb-0 small">This bucket is limited to ' + quotaParts.join(' and ') + '.</p></div>';
|
||||||
|
} else {
|
||||||
|
alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4';
|
||||||
|
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>' +
|
||||||
|
'<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><div><strong>No storage quota</strong>' +
|
||||||
|
'<p class="mb-0 small">This bucket has no storage or object count limits. Set limits below to control usage.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>' +
|
||||||
|
'</svg><div><strong>Policy attached</strong>' +
|
||||||
|
'<p class="mb-0 small">A bucket policy is attached to this bucket. Access is granted via both IAM and bucket policy rules.</p></div>';
|
||||||
|
} else {
|
||||||
|
alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4';
|
||||||
|
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>' +
|
||||||
|
'<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>' +
|
||||||
|
'</svg><div><strong>IAM only</strong>' +
|
||||||
|
'<p class="mb-0 small">No bucket policy is attached. Access is controlled by IAM policies only.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 || {};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
344
static/js/connections-management.js
Normal file
344
static/js/connections-management.js
Normal file
@@ -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 = '<div class="text-info"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Testing connection...</div>';
|
||||||
|
|
||||||
|
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 = '<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>' + window.UICore.escapeHtml(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>' + window.UICore.escapeHtml(result.message) + '</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
var message = error.name === 'AbortError'
|
||||||
|
? 'Connection test timed out - endpoint may be unreachable'
|
||||||
|
: 'Connection failed: Network error';
|
||||||
|
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>' + message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '<tr data-connection-id="' + window.UICore.escapeHtml(conn.id) + '">' +
|
||||||
|
'<td class="text-center">' +
|
||||||
|
'<span class="connection-status" data-status="checking" title="Checking...">' +
|
||||||
|
'<span class="spinner-border spinner-border-sm text-muted" role="status" style="width: 12px; height: 12px;"></span>' +
|
||||||
|
'</span></td>' +
|
||||||
|
'<td><div class="d-flex align-items-center gap-2">' +
|
||||||
|
'<div class="connection-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383z"/></svg></div>' +
|
||||||
|
'<span class="fw-medium">' + window.UICore.escapeHtml(conn.name) + '</span>' +
|
||||||
|
'</div></td>' +
|
||||||
|
'<td><span class="text-muted small text-truncate d-inline-block" style="max-width: 200px;" title="' + window.UICore.escapeHtml(conn.endpoint_url) + '">' + window.UICore.escapeHtml(conn.endpoint_url) + '</span></td>' +
|
||||||
|
'<td><span class="badge bg-primary bg-opacity-10 text-primary">' + window.UICore.escapeHtml(conn.region) + '</span></td>' +
|
||||||
|
'<td><code class="small">' + window.UICore.escapeHtml(maskedKey) + '</code></td>' +
|
||||||
|
'<td class="text-end"><div class="btn-group btn-group-sm" role="group">' +
|
||||||
|
'<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editConnectionModal" ' +
|
||||||
|
'data-id="' + window.UICore.escapeHtml(conn.id) + '" data-name="' + window.UICore.escapeHtml(conn.name) + '" ' +
|
||||||
|
'data-endpoint="' + window.UICore.escapeHtml(conn.endpoint_url) + '" data-region="' + window.UICore.escapeHtml(conn.region) + '" ' +
|
||||||
|
'data-access="' + window.UICore.escapeHtml(conn.access_key) + '" data-secret="' + window.UICore.escapeHtml(conn.secret_key || '') + '" title="Edit connection">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg></button>' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteConnectionModal" ' +
|
||||||
|
'data-id="' + window.UICore.escapeHtml(conn.id) + '" data-name="' + window.UICore.escapeHtml(conn.name) + '" title="Delete connection">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" 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></button>' +
|
||||||
|
'</div></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="table-responsive"><table class="table table-hover align-middle mb-0">' +
|
||||||
|
'<thead class="table-light"><tr>' +
|
||||||
|
'<th scope="col" style="width: 50px;">Status</th>' +
|
||||||
|
'<th scope="col">Name</th><th scope="col">Endpoint</th>' +
|
||||||
|
'<th scope="col">Region</th><th scope="col">Access Key</th>' +
|
||||||
|
'<th scope="col" class="text-end">Actions</th></tr></thead>' +
|
||||||
|
'<tbody></tbody></table></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
545
static/js/iam-management.js
Normal file
545
static/js/iam-management.js
Normal file
@@ -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 '<span class="badge bg-primary bg-opacity-10 text-primary">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
||||||
|
'</svg>' + window.UICore.escapeHtml(p.bucket) +
|
||||||
|
'<span class="opacity-75">(' + actionText + ')</span></span>';
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
policyBadges = '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="col-md-6 col-xl-4">' +
|
||||||
|
'<div class="card h-100 iam-user-card">' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<div class="d-flex align-items-start justify-content-between mb-3">' +
|
||||||
|
'<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">' +
|
||||||
|
'<div class="user-avatar user-avatar-lg flex-shrink-0">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>' +
|
||||||
|
'</svg></div>' +
|
||||||
|
'<div class="min-width-0">' +
|
||||||
|
'<h6 class="fw-semibold mb-0 text-truncate" title="' + window.UICore.escapeHtml(displayName) + '">' + window.UICore.escapeHtml(displayName) + '</h6>' +
|
||||||
|
'<code class="small text-muted d-block text-truncate" title="' + window.UICore.escapeHtml(accessKey) + '">' + window.UICore.escapeHtml(accessKey) + '</code>' +
|
||||||
|
'</div></div>' +
|
||||||
|
'<div class="dropdown flex-shrink-0">' +
|
||||||
|
'<button class="btn btn-sm btn-icon" type="button" data-bs-toggle="dropdown" aria-expanded="false">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>' +
|
||||||
|
'</svg></button>' +
|
||||||
|
'<ul class="dropdown-menu dropdown-menu-end">' +
|
||||||
|
'<li><button class="dropdown-item" type="button" data-edit-user="' + window.UICore.escapeHtml(accessKey) + '" data-display-name="' + window.UICore.escapeHtml(displayName) + '">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg>Edit Name</button></li>' +
|
||||||
|
'<li><button class="dropdown-item" type="button" data-rotate-user="' + window.UICore.escapeHtml(accessKey) + '">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>Rotate Secret</button></li>' +
|
||||||
|
'<li><hr class="dropdown-divider"></li>' +
|
||||||
|
'<li><button class="dropdown-item text-danger" type="button" data-delete-user="' + window.UICore.escapeHtml(accessKey) + '">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M5.5 5.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 .5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 1 0z"/><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 User</button></li>' +
|
||||||
|
'</ul></div></div>' +
|
||||||
|
'<div class="mb-3">' +
|
||||||
|
'<div class="small text-muted mb-2">Bucket Permissions</div>' +
|
||||||
|
'<div class="d-flex flex-wrap gap-1">' + policyBadges + '</div></div>' +
|
||||||
|
'<button class="btn btn-outline-primary btn-sm w-100" type="button" data-policy-editor data-access-key="' + window.UICore.escapeHtml(accessKey) + '">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>Manage Policies</button>' +
|
||||||
|
'</div></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="alert alert-info border-0 shadow-sm mb-4" role="alert" id="newUserSecretAlert">' +
|
||||||
|
'<div class="d-flex align-items-start gap-2 mb-2">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-key flex-shrink-0 mt-1" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M0 8a4 4 0 0 1 7.465-2H14a.5.5 0 0 1 .354.146l1.5 1.5a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0L13 9.207l-.646.647a.5.5 0 0 1-.708 0L11 9.207l-.646.647a.5.5 0 0 1-.708 0L9 9.207l-.646.647A.5.5 0 0 1 8 10h-.535A4 4 0 0 1 0 8zm4-3a3 3 0 1 0 2.712 4.285A.5.5 0 0 1 7.163 9h.63l.853-.854a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.793-.793-1-1h-6.63a.5.5 0 0 1-.451-.285A3 3 0 0 0 4 5z"/><path d="M4 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>' +
|
||||||
|
'</svg>' +
|
||||||
|
'<div class="flex-grow-1">' +
|
||||||
|
'<div class="fw-semibold">New user created: <code>' + window.UICore.escapeHtml(data.access_key) + '</code></div>' +
|
||||||
|
'<p class="mb-2 small">This secret is only shown once. Copy it now and store it securely.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="input-group">' +
|
||||||
|
'<span class="input-group-text"><strong>Secret key</strong></span>' +
|
||||||
|
'<input class="form-control font-monospace" type="text" value="' + window.UICore.escapeHtml(data.secret_key) + '" readonly id="newUserSecret" />' +
|
||||||
|
'<button class="btn btn-outline-primary" type="button" id="copyNewUserSecret">Copy</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
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 = '<div class="row g-3"></div>';
|
||||||
|
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 '<span class="badge bg-primary bg-opacity-10 text-primary">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
||||||
|
'</svg>' + window.UICore.escapeHtml(p.bucket) +
|
||||||
|
'<span class="opacity-75">(' + (p.actions.includes('*') ? 'full' : p.actions.length) + ')</span></span>';
|
||||||
|
}).join('');
|
||||||
|
badgeContainer.innerHTML = badges || '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
311
static/js/ui-core.js
Normal file
311
static/js/ui-core.js
Normal file
@@ -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, '"')
|
||||||
|
.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 = '<span class="spinner-border spinner-border-sm me-1"></span>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 = '<span class="spinner-border spinner-border-sm me-1"></span>' + (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 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -393,6 +393,8 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/ui-core.js') }}"></script>
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -473,15 +473,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Save Policy
|
Save Policy
|
||||||
</button>
|
</button>
|
||||||
{% if bucket_policy %}
|
<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 %}>
|
||||||
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deletePolicyModal">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<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 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"/>
|
<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>
|
</svg>
|
||||||
Delete Policy
|
Delete Policy
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -636,7 +634,7 @@
|
|||||||
Suspend Versioning
|
Suspend Versioning
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<input type="hidden" name="state" value="enable" />
|
<input type="hidden" name="state" value="enable" />
|
||||||
<button class="btn btn-success" type="submit">
|
<button class="btn btn-success" type="submit">
|
||||||
@@ -931,7 +929,8 @@
|
|||||||
{% if can_manage_quota %}
|
{% if can_manage_quota %}
|
||||||
<form method="post" action="{{ url_for('ui.update_bucket_quota', bucket_name=bucket_name) }}" id="quotaForm">
|
<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="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="action" value="set" id="quotaAction" />
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="max_mb" class="form-label fw-medium">Maximum Storage Size</label>
|
<label for="max_mb" class="form-label fw-medium">Maximum Storage Size</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -958,14 +957,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Save Quota Settings
|
Save Quota Settings
|
||||||
</button>
|
</button>
|
||||||
{% if has_quota %}
|
<button type="button" class="btn btn-outline-danger" id="removeQuotaBtn"{% if not has_quota %} style="display: none;"{% endif %}>
|
||||||
<button type="submit" class="btn btn-outline-danger" id="removeQuotaBtn" name="action" value="remove">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Remove Quota
|
Remove Quota
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -1856,7 +1853,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-0 pt-0">
|
<div class="modal-footer border-0 pt-0">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<input type="hidden" name="mode" value="delete" />
|
<input type="hidden" name="mode" value="delete" />
|
||||||
<button type="submit" class="btn btn-danger">Delete Policy</button>
|
<button type="submit" class="btn btn-danger">Delete Policy</button>
|
||||||
@@ -2037,7 +2034,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</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() }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="alert alert-danger d-flex align-items-center mb-3" role="alert">
|
<div class="alert alert-danger d-flex align-items-center mb-3" role="alert">
|
||||||
@@ -2267,7 +2264,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<input type="hidden" name="state" value="suspend" />
|
<input type="hidden" name="state" value="suspend" />
|
||||||
<button type="submit" class="btn btn-warning">
|
<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-upload.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/bucket-detail-operations.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 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</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() }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<div class="modal-body pt-0">
|
<div class="modal-body pt-0">
|
||||||
<label class="form-label fw-medium">Bucket name</label>
|
<label class="form-label fw-medium">Bucket name</label>
|
||||||
@@ -205,6 +205,25 @@
|
|||||||
});
|
});
|
||||||
row.style.cursor = 'pointer';
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
<label for="secret_key" class="form-label fw-medium">Secret Key</label>
|
<label for="secret_key" class="form-label fw-medium">Secret Key</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control font-monospace" id="secret_key" name="secret_key" required>
|
<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">
|
<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="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"/>
|
<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>
|
<label for="edit_secret_key" class="form-label fw-medium">Secret Key</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control font-monospace" id="edit_secret_key" name="secret_key" required>
|
<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">
|
<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="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"/>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/connections-management.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function togglePassword(id) {
|
ConnectionsManagement.init({
|
||||||
const input = document.getElementById(id);
|
csrfToken: "{{ csrf_token() }}",
|
||||||
if (input.type === "password") {
|
endpoints: {
|
||||||
input.type = "text";
|
test: "{{ url_for('ui.test_connection') }}",
|
||||||
} else {
|
updateTemplate: "{{ url_for('ui.update_connection', connection_id='CONNECTION_ID') }}",
|
||||||
input.type = "password";
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -454,339 +454,20 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
<script src="{{ url_for('static', filename='js/iam-management.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
IAMManagement.init({
|
||||||
function setupJsonAutoIndent(textarea) {
|
users: JSON.parse(document.getElementById('iamUsersJson').textContent || '[]'),
|
||||||
if (!textarea) return;
|
currentUserKey: {{ principal.access_key | tojson }},
|
||||||
|
iamLocked: {{ iam_locked | tojson }},
|
||||||
textarea.addEventListener('keydown', function(e) {
|
csrfToken: "{{ csrf_token() }}",
|
||||||
if (e.key === 'Enter') {
|
endpoints: {
|
||||||
e.preventDefault();
|
createUser: "{{ url_for('ui.create_iam_user') }}",
|
||||||
|
updateUser: "{{ url_for('ui.update_iam_user', access_key='ACCESS_KEY') }}",
|
||||||
const start = this.selectionStart;
|
deleteUser: "{{ url_for('ui.delete_iam_user', access_key='ACCESS_KEY') }}",
|
||||||
const end = this.selectionEnd;
|
updatePolicies: "{{ url_for('ui.update_iam_policies', access_key='ACCESS_KEY') }}",
|
||||||
const value = this.value;
|
rotateSecret: "{{ url_for('ui.rotate_iam_secret', access_key='ACCESS_KEY') }}"
|
||||||
|
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
<p class="text-muted mb-0">Real-time server performance and storage usage</p>
|
<p class="text-muted mb-0">Real-time server performance and storage usage</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 align-items-center">
|
<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>
|
<span class="live-indicator"></span>
|
||||||
Live
|
Auto-refresh: <span id="refreshCountdown">5</span>s
|
||||||
</span>
|
</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">
|
<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 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"/>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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" 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>
|
||||||
<div class="mt-2 d-flex justify-content-between">
|
<div class="mt-2 d-flex justify-content-between">
|
||||||
<small class="text-muted">Current load</small>
|
<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 %}">
|
<small data-metric="cpu_status" class="text-success">Normal</small>
|
||||||
{% if cpu_percent > 80 %}High{% elif cpu_percent > 50 %}Medium{% else %}Normal{% endif %}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,13 +55,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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" 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>
|
||||||
<div class="mt-2 d-flex justify-content-between">
|
<div class="mt-2 d-flex justify-content-between">
|
||||||
<small class="text-muted">{{ memory.used }} used</small>
|
<small class="text-muted"><span data-metric="memory_used">{{ memory.used }}</span> used</small>
|
||||||
<small class="text-muted">{{ memory.total }} total</small>
|
<small class="text-muted"><span data-metric="memory_total">{{ memory.total }}</span> total</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,13 +79,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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" 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>
|
||||||
<div class="mt-2 d-flex justify-content-between">
|
<div class="mt-2 d-flex justify-content-between">
|
||||||
<small class="text-muted">{{ disk.free }} free</small>
|
<small class="text-muted"><span data-metric="disk_free">{{ disk.free }}</span> free</small>
|
||||||
<small class="text-muted">{{ disk.total }} total</small>
|
<small class="text-muted"><span data-metric="disk_total">{{ disk.total }}</span> total</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,15 +102,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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="d-flex gap-3 mt-3">
|
||||||
<div class="text-center flex-fill">
|
<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>
|
<small class="text-muted">Buckets</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="vr"></div>
|
<div class="vr"></div>
|
||||||
<div class="text-center flex-fill">
|
<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>
|
<small class="text-muted">Objects</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,3 +268,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user