UI overhaul; Replication and S3 API improvements
This commit is contained in:
@@ -105,6 +105,18 @@ def create_app(
|
||||
value /= 1024.0
|
||||
return f"{value:.1f} PB"
|
||||
|
||||
@app.template_filter("timestamp_to_datetime")
|
||||
def timestamp_to_datetime(value: float) -> str:
|
||||
"""Format Unix timestamp as human-readable datetime."""
|
||||
from datetime import datetime
|
||||
if not value:
|
||||
return "Never"
|
||||
try:
|
||||
dt = datetime.fromtimestamp(value)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, OSError):
|
||||
return "Unknown"
|
||||
|
||||
if include_api:
|
||||
from .s3_api import s3_api_bp
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class AppConfig:
|
||||
secret_ttl_seconds: int
|
||||
stream_chunk_size: int
|
||||
multipart_min_part_size: int
|
||||
bucket_stats_cache_ttl: int
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
||||
@@ -85,8 +86,6 @@ class AppConfig:
|
||||
default_secret = "dev-secret-key"
|
||||
secret_key = str(_get("SECRET_KEY", default_secret))
|
||||
|
||||
# If using default/missing secret, try to load/persist a generated one from disk
|
||||
# This ensures consistency across Gunicorn workers
|
||||
if not secret_key or secret_key == default_secret:
|
||||
secret_file = storage_root / ".myfsio.sys" / "config" / ".secret"
|
||||
if secret_file.exists():
|
||||
@@ -100,7 +99,6 @@ class AppConfig:
|
||||
secret_file.write_text(generated)
|
||||
secret_key = generated
|
||||
except OSError:
|
||||
# Fallback if we can't write to disk (e.g. read-only fs)
|
||||
secret_key = generated
|
||||
|
||||
iam_env_override = "IAM_CONFIG" in overrides or "IAM_CONFIG" in os.environ
|
||||
@@ -156,6 +154,7 @@ class AppConfig:
|
||||
"X-Amz-Signature",
|
||||
])
|
||||
session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30))
|
||||
bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60)) # Default 60 seconds
|
||||
|
||||
return cls(storage_root=storage_root,
|
||||
max_upload_size=max_upload_size,
|
||||
@@ -182,7 +181,8 @@ class AppConfig:
|
||||
bulk_delete_max_keys=bulk_delete_max_keys,
|
||||
secret_ttl_seconds=secret_ttl_seconds,
|
||||
stream_chunk_size=stream_chunk_size,
|
||||
multipart_min_part_size=multipart_min_part_size)
|
||||
multipart_min_part_size=multipart_min_part_size,
|
||||
bucket_stats_cache_ttl=bucket_stats_cache_ttl)
|
||||
|
||||
def to_flask_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -202,6 +202,7 @@ class AppConfig:
|
||||
"SECRET_TTL_SECONDS": self.secret_ttl_seconds,
|
||||
"STREAM_CHUNK_SIZE": self.stream_chunk_size,
|
||||
"MULTIPART_MIN_PART_SIZE": self.multipart_min_part_size,
|
||||
"BUCKET_STATS_CACHE_TTL": self.bucket_stats_cache_ttl,
|
||||
"LOG_LEVEL": self.log_level,
|
||||
"LOG_FILE": str(self.log_path),
|
||||
"LOG_MAX_BYTES": self.log_max_bytes,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Background replication worker."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -21,6 +23,41 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0"
|
||||
|
||||
REPLICATION_MODE_NEW_ONLY = "new_only"
|
||||
REPLICATION_MODE_ALL = "all"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplicationStats:
|
||||
"""Statistics for replication operations - computed dynamically."""
|
||||
objects_synced: int = 0 # Objects that exist in both source and destination
|
||||
objects_pending: int = 0 # Objects in source but not in destination
|
||||
objects_orphaned: int = 0 # Objects in destination but not in source (will be deleted)
|
||||
bytes_synced: int = 0 # Total bytes synced to destination
|
||||
last_sync_at: Optional[float] = None
|
||||
last_sync_key: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"objects_synced": self.objects_synced,
|
||||
"objects_pending": self.objects_pending,
|
||||
"objects_orphaned": self.objects_orphaned,
|
||||
"bytes_synced": self.bytes_synced,
|
||||
"last_sync_at": self.last_sync_at,
|
||||
"last_sync_key": self.last_sync_key,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ReplicationStats":
|
||||
return cls(
|
||||
objects_synced=data.get("objects_synced", 0),
|
||||
objects_pending=data.get("objects_pending", 0),
|
||||
objects_orphaned=data.get("objects_orphaned", 0),
|
||||
bytes_synced=data.get("bytes_synced", 0),
|
||||
last_sync_at=data.get("last_sync_at"),
|
||||
last_sync_key=data.get("last_sync_key"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplicationRule:
|
||||
@@ -28,6 +65,32 @@ class ReplicationRule:
|
||||
target_connection_id: str
|
||||
target_bucket: str
|
||||
enabled: bool = True
|
||||
mode: str = REPLICATION_MODE_NEW_ONLY
|
||||
created_at: Optional[float] = None
|
||||
stats: ReplicationStats = field(default_factory=ReplicationStats)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"bucket_name": self.bucket_name,
|
||||
"target_connection_id": self.target_connection_id,
|
||||
"target_bucket": self.target_bucket,
|
||||
"enabled": self.enabled,
|
||||
"mode": self.mode,
|
||||
"created_at": self.created_at,
|
||||
"stats": self.stats.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ReplicationRule":
|
||||
stats_data = data.pop("stats", {})
|
||||
# Handle old rules without mode/created_at
|
||||
if "mode" not in data:
|
||||
data["mode"] = REPLICATION_MODE_NEW_ONLY
|
||||
if "created_at" not in data:
|
||||
data["created_at"] = None
|
||||
rule = cls(**data)
|
||||
rule.stats = ReplicationStats.from_dict(stats_data) if stats_data else ReplicationStats()
|
||||
return rule
|
||||
|
||||
|
||||
class ReplicationManager:
|
||||
@@ -36,6 +99,7 @@ class ReplicationManager:
|
||||
self.connections = connections
|
||||
self.rules_path = rules_path
|
||||
self._rules: Dict[str, ReplicationRule] = {}
|
||||
self._stats_lock = threading.Lock()
|
||||
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="ReplicationWorker")
|
||||
self.reload_rules()
|
||||
|
||||
@@ -44,17 +108,15 @@ class ReplicationManager:
|
||||
self._rules = {}
|
||||
return
|
||||
try:
|
||||
import json
|
||||
with open(self.rules_path, "r") as f:
|
||||
data = json.load(f)
|
||||
for bucket, rule_data in data.items():
|
||||
self._rules[bucket] = ReplicationRule(**rule_data)
|
||||
self._rules[bucket] = ReplicationRule.from_dict(rule_data)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.error(f"Failed to load replication rules: {e}")
|
||||
|
||||
def save_rules(self) -> None:
|
||||
import json
|
||||
data = {b: rule.__dict__ for b, rule in self._rules.items()}
|
||||
data = {b: rule.to_dict() for b, rule in self._rules.items()}
|
||||
self.rules_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.rules_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
@@ -71,6 +133,99 @@ class ReplicationManager:
|
||||
del self._rules[bucket_name]
|
||||
self.save_rules()
|
||||
|
||||
def _update_last_sync(self, bucket_name: str, object_key: str = "") -> None:
|
||||
"""Update last sync timestamp after a successful operation."""
|
||||
with self._stats_lock:
|
||||
rule = self._rules.get(bucket_name)
|
||||
if not rule:
|
||||
return
|
||||
rule.stats.last_sync_at = time.time()
|
||||
rule.stats.last_sync_key = object_key
|
||||
self.save_rules()
|
||||
|
||||
def get_sync_status(self, bucket_name: str) -> Optional[ReplicationStats]:
|
||||
"""Dynamically compute replication status by comparing source and destination buckets."""
|
||||
rule = self.get_rule(bucket_name)
|
||||
if not rule:
|
||||
return None
|
||||
|
||||
connection = self.connections.get(rule.target_connection_id)
|
||||
if not connection:
|
||||
return rule.stats # Return cached stats if connection unavailable
|
||||
|
||||
try:
|
||||
# Get source objects
|
||||
source_objects = self.storage.list_objects(bucket_name)
|
||||
source_keys = {obj.key: obj.size for obj in source_objects}
|
||||
|
||||
# Get destination objects
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=connection.endpoint_url,
|
||||
aws_access_key_id=connection.access_key,
|
||||
aws_secret_access_key=connection.secret_key,
|
||||
region_name=connection.region,
|
||||
)
|
||||
|
||||
dest_keys = set()
|
||||
bytes_synced = 0
|
||||
paginator = s3.get_paginator('list_objects_v2')
|
||||
try:
|
||||
for page in paginator.paginate(Bucket=rule.target_bucket):
|
||||
for obj in page.get('Contents', []):
|
||||
dest_keys.add(obj['Key'])
|
||||
if obj['Key'] in source_keys:
|
||||
bytes_synced += obj.get('Size', 0)
|
||||
except ClientError as e:
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
# Destination bucket doesn't exist yet
|
||||
dest_keys = set()
|
||||
else:
|
||||
raise
|
||||
|
||||
# Compute stats
|
||||
synced = source_keys.keys() & dest_keys # Objects in both
|
||||
orphaned = dest_keys - source_keys.keys() # In dest but not source
|
||||
|
||||
# For "new_only" mode, we can't determine pending since we don't know
|
||||
# which objects existed before replication was enabled. Only "all" mode
|
||||
# should show pending (objects that should be replicated but aren't yet).
|
||||
if rule.mode == REPLICATION_MODE_ALL:
|
||||
pending = source_keys.keys() - dest_keys # In source but not dest
|
||||
else:
|
||||
pending = set() # New-only mode: don't show pre-existing as pending
|
||||
|
||||
# Update cached stats with computed values
|
||||
rule.stats.objects_synced = len(synced)
|
||||
rule.stats.objects_pending = len(pending)
|
||||
rule.stats.objects_orphaned = len(orphaned)
|
||||
rule.stats.bytes_synced = bytes_synced
|
||||
|
||||
return rule.stats
|
||||
|
||||
except (ClientError, StorageError) as e:
|
||||
logger.error(f"Failed to compute sync status for {bucket_name}: {e}")
|
||||
return rule.stats # Return cached stats on error
|
||||
|
||||
def replicate_existing_objects(self, bucket_name: str) -> None:
|
||||
"""Trigger replication for all existing objects in a bucket."""
|
||||
rule = self.get_rule(bucket_name)
|
||||
if not rule or not rule.enabled:
|
||||
return
|
||||
|
||||
connection = self.connections.get(rule.target_connection_id)
|
||||
if not connection:
|
||||
logger.warning(f"Cannot replicate existing objects: Connection {rule.target_connection_id} not found")
|
||||
return
|
||||
|
||||
try:
|
||||
objects = self.storage.list_objects(bucket_name)
|
||||
logger.info(f"Starting replication of {len(objects)} existing objects from {bucket_name}")
|
||||
for obj in objects:
|
||||
self._executor.submit(self._replicate_task, bucket_name, obj.key, rule, connection, "write")
|
||||
except StorageError as e:
|
||||
logger.error(f"Failed to list objects for replication: {e}")
|
||||
|
||||
def create_remote_bucket(self, connection_id: str, bucket_name: str) -> None:
|
||||
"""Create a bucket on the remote connection."""
|
||||
connection = self.connections.get(connection_id)
|
||||
@@ -103,6 +258,7 @@ class ReplicationManager:
|
||||
self._executor.submit(self._replicate_task, bucket_name, object_key, rule, connection, action)
|
||||
|
||||
def _replicate_task(self, bucket_name: str, object_key: str, rule: ReplicationRule, conn: RemoteConnection, action: str) -> None:
|
||||
file_size = 0
|
||||
try:
|
||||
# Using boto3 to upload
|
||||
config = Config(user_agent_extra=REPLICATION_USER_AGENT)
|
||||
@@ -119,21 +275,15 @@ class ReplicationManager:
|
||||
try:
|
||||
s3.delete_object(Bucket=rule.target_bucket, Key=object_key)
|
||||
logger.info(f"Replicated DELETE {bucket_name}/{object_key} to {conn.name} ({rule.target_bucket})")
|
||||
self._update_last_sync(bucket_name, object_key)
|
||||
except ClientError as e:
|
||||
logger.error(f"Replication DELETE failed for {bucket_name}/{object_key}: {e}")
|
||||
return
|
||||
|
||||
# 1. Get local file path
|
||||
# Note: We are accessing internal storage structure here.
|
||||
# Ideally storage.py should expose a 'get_file_path' or we read the stream.
|
||||
# For efficiency, we'll try to read the file directly if we can, or use storage.get_object
|
||||
|
||||
# We need the file content.
|
||||
# Since ObjectStorage is filesystem based, let's get the stream.
|
||||
# We need to be careful about closing it.
|
||||
try:
|
||||
path = self.storage.get_object_path(bucket_name, object_key)
|
||||
except StorageError:
|
||||
logger.error(f"Source object not found: {bucket_name}/{object_key}")
|
||||
return
|
||||
|
||||
metadata = self.storage.get_object_metadata(bucket_name, object_key)
|
||||
@@ -159,7 +309,6 @@ class ReplicationManager:
|
||||
Metadata=metadata or {}
|
||||
)
|
||||
except (ClientError, S3UploadFailedError) as e:
|
||||
# Check if it's a NoSuchBucket error (either direct or wrapped)
|
||||
is_no_bucket = False
|
||||
if isinstance(e, ClientError):
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
@@ -189,6 +338,7 @@ class ReplicationManager:
|
||||
raise e
|
||||
|
||||
logger.info(f"Replicated {bucket_name}/{object_key} to {conn.name} ({rule.target_bucket})")
|
||||
self._update_last_sync(bucket_name, object_key)
|
||||
|
||||
except (ClientError, OSError, ValueError) as e:
|
||||
logger.error(f"Replication failed for {bucket_name}/{object_key}: {e}")
|
||||
|
||||
479
app/s3_api.py
479
app/s3_api.py
@@ -584,6 +584,73 @@ def _render_tagging_document(tags: list[dict[str, str]]) -> Element:
|
||||
SubElement(tag_el, "Value").text = tag.get("Value", "")
|
||||
return root
|
||||
|
||||
DANGEROUS_CONTENT_TYPES = frozenset([
|
||||
"text/html",
|
||||
"application/xhtml+xml",
|
||||
"application/javascript",
|
||||
"text/javascript",
|
||||
"application/x-javascript",
|
||||
"text/ecmascript",
|
||||
"application/ecmascript",
|
||||
"image/svg+xml",
|
||||
])
|
||||
|
||||
SAFE_EXTENSION_MAP = {
|
||||
".txt": ["text/plain"],
|
||||
".json": ["application/json"],
|
||||
".xml": ["application/xml", "text/xml"],
|
||||
".csv": ["text/csv"],
|
||||
".pdf": ["application/pdf"],
|
||||
".png": ["image/png"],
|
||||
".jpg": ["image/jpeg"],
|
||||
".jpeg": ["image/jpeg"],
|
||||
".gif": ["image/gif"],
|
||||
".webp": ["image/webp"],
|
||||
".mp4": ["video/mp4"],
|
||||
".mp3": ["audio/mpeg"],
|
||||
".zip": ["application/zip"],
|
||||
".gz": ["application/gzip"],
|
||||
".tar": ["application/x-tar"],
|
||||
}
|
||||
|
||||
|
||||
def _validate_content_type(object_key: str, content_type: str | None) -> str | None:
|
||||
"""Validate Content-Type header for security.
|
||||
|
||||
Returns an error message if validation fails, None otherwise.
|
||||
|
||||
Rules:
|
||||
1. Block dangerous MIME types that can execute scripts (unless explicitly allowed)
|
||||
2. Warn if Content-Type doesn't match file extension (but don't block)
|
||||
"""
|
||||
if not content_type:
|
||||
return None
|
||||
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
if base_type in DANGEROUS_CONTENT_TYPES:
|
||||
ext = "." + object_key.rsplit(".", 1)[-1].lower() if "." in object_key else ""
|
||||
|
||||
allowed_dangerous = {
|
||||
".svg": "image/svg+xml",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".xhtml": "application/xhtml+xml",
|
||||
".js": "application/javascript",
|
||||
".mjs": "application/javascript",
|
||||
}
|
||||
|
||||
if ext in allowed_dangerous and base_type == allowed_dangerous[ext]:
|
||||
return None
|
||||
|
||||
return (
|
||||
f"Content-Type '{content_type}' is potentially dangerous and not allowed "
|
||||
f"for object key '{object_key}'. Use a safe Content-Type or rename the file "
|
||||
f"with an appropriate extension."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_cors_document(payload: bytes) -> list[dict[str, Any]]:
|
||||
try:
|
||||
@@ -731,6 +798,8 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
|
||||
"tagging": _bucket_tagging_handler,
|
||||
"cors": _bucket_cors_handler,
|
||||
"encryption": _bucket_encryption_handler,
|
||||
"location": _bucket_location_handler,
|
||||
"acl": _bucket_acl_handler,
|
||||
}
|
||||
requested = [key for key in handlers if key in request.args]
|
||||
if not requested:
|
||||
@@ -746,8 +815,8 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
|
||||
|
||||
|
||||
def _bucket_versioning_handler(bucket_name: str) -> Response:
|
||||
if request.method != "GET":
|
||||
return _method_not_allowed(["GET"])
|
||||
if request.method not in {"GET", "PUT"}:
|
||||
return _method_not_allowed(["GET", "PUT"])
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
return error
|
||||
@@ -756,6 +825,31 @@ def _bucket_versioning_handler(bucket_name: str) -> Response:
|
||||
except IamError as exc:
|
||||
return _error_response("AccessDenied", str(exc), 403)
|
||||
storage = _storage()
|
||||
|
||||
if request.method == "PUT":
|
||||
payload = request.get_data(cache=False) or b""
|
||||
if not payload.strip():
|
||||
return _error_response("MalformedXML", "Request body is required", 400)
|
||||
try:
|
||||
root = fromstring(payload)
|
||||
except ParseError:
|
||||
return _error_response("MalformedXML", "Unable to parse XML document", 400)
|
||||
if _strip_ns(root.tag) != "VersioningConfiguration":
|
||||
return _error_response("MalformedXML", "Root element must be VersioningConfiguration", 400)
|
||||
status_el = root.find("{*}Status")
|
||||
if status_el is None:
|
||||
status_el = root.find("Status")
|
||||
status = (status_el.text or "").strip() if status_el is not None else ""
|
||||
if status not in {"Enabled", "Suspended", ""}:
|
||||
return _error_response("MalformedXML", "Status must be Enabled or Suspended", 400)
|
||||
try:
|
||||
storage.set_bucket_versioning(bucket_name, status == "Enabled")
|
||||
except StorageError as exc:
|
||||
return _error_response("NoSuchBucket", str(exc), 404)
|
||||
current_app.logger.info("Bucket versioning updated", extra={"bucket": bucket_name, "status": status})
|
||||
return Response(status=200)
|
||||
|
||||
# GET
|
||||
try:
|
||||
enabled = storage.is_versioning_enabled(bucket_name)
|
||||
except StorageError as exc:
|
||||
@@ -766,8 +860,8 @@ def _bucket_versioning_handler(bucket_name: str) -> Response:
|
||||
|
||||
|
||||
def _bucket_tagging_handler(bucket_name: str) -> Response:
|
||||
if request.method not in {"GET", "PUT"}:
|
||||
return _method_not_allowed(["GET", "PUT"])
|
||||
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
return error
|
||||
@@ -784,6 +878,14 @@ def _bucket_tagging_handler(bucket_name: str) -> Response:
|
||||
if not tags:
|
||||
return _error_response("NoSuchTagSet", "No tags are configured for this bucket", 404)
|
||||
return _xml_response(_render_tagging_document(tags))
|
||||
if request.method == "DELETE":
|
||||
try:
|
||||
storage.set_bucket_tags(bucket_name, None)
|
||||
except StorageError as exc:
|
||||
return _error_response("NoSuchBucket", str(exc), 404)
|
||||
current_app.logger.info("Bucket tags deleted", extra={"bucket": bucket_name})
|
||||
return Response(status=204)
|
||||
# PUT
|
||||
payload = request.get_data(cache=False) or b""
|
||||
try:
|
||||
tags = _parse_tagging_document(payload)
|
||||
@@ -799,6 +901,64 @@ def _bucket_tagging_handler(bucket_name: str) -> Response:
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
def _object_tagging_handler(bucket_name: str, object_key: str) -> Response:
|
||||
"""Handle object tagging operations (GET/PUT/DELETE /<bucket>/<key>?tagging)."""
|
||||
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
return error
|
||||
|
||||
# For tagging, we use read permission for GET, write for PUT/DELETE
|
||||
action = "read" if request.method == "GET" else "write"
|
||||
try:
|
||||
_authorize_action(principal, bucket_name, action, object_key=object_key)
|
||||
except IamError as exc:
|
||||
return _error_response("AccessDenied", str(exc), 403)
|
||||
|
||||
storage = _storage()
|
||||
|
||||
if request.method == "GET":
|
||||
try:
|
||||
tags = storage.get_object_tags(bucket_name, object_key)
|
||||
except StorageError as exc:
|
||||
message = str(exc)
|
||||
if "Bucket" in message:
|
||||
return _error_response("NoSuchBucket", message, 404)
|
||||
return _error_response("NoSuchKey", message, 404)
|
||||
return _xml_response(_render_tagging_document(tags))
|
||||
|
||||
if request.method == "DELETE":
|
||||
try:
|
||||
storage.delete_object_tags(bucket_name, object_key)
|
||||
except StorageError as exc:
|
||||
message = str(exc)
|
||||
if "Bucket" in message:
|
||||
return _error_response("NoSuchBucket", message, 404)
|
||||
return _error_response("NoSuchKey", message, 404)
|
||||
current_app.logger.info("Object tags deleted", extra={"bucket": bucket_name, "key": object_key})
|
||||
return Response(status=204)
|
||||
|
||||
# PUT
|
||||
payload = request.get_data(cache=False) or b""
|
||||
try:
|
||||
tags = _parse_tagging_document(payload)
|
||||
except ValueError as exc:
|
||||
return _error_response("MalformedXML", str(exc), 400)
|
||||
if len(tags) > 10:
|
||||
return _error_response("InvalidTag", "A maximum of 10 tags is supported for objects", 400)
|
||||
try:
|
||||
storage.set_object_tags(bucket_name, object_key, tags)
|
||||
except StorageError as exc:
|
||||
message = str(exc)
|
||||
if "Bucket" in message:
|
||||
return _error_response("NoSuchBucket", message, 404)
|
||||
return _error_response("NoSuchKey", message, 404)
|
||||
current_app.logger.info("Object tags updated", extra={"bucket": bucket_name, "key": object_key, "tags": len(tags)})
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
def _sanitize_cors_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
sanitized: list[dict[str, Any]] = []
|
||||
for rule in rules:
|
||||
@@ -823,8 +983,8 @@ def _sanitize_cors_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _bucket_cors_handler(bucket_name: str) -> Response:
|
||||
if request.method not in {"GET", "PUT"}:
|
||||
return _method_not_allowed(["GET", "PUT"])
|
||||
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
return error
|
||||
@@ -841,6 +1001,14 @@ def _bucket_cors_handler(bucket_name: str) -> Response:
|
||||
if not rules:
|
||||
return _error_response("NoSuchCORSConfiguration", "No CORS configuration found", 404)
|
||||
return _xml_response(_render_cors_document(rules))
|
||||
if request.method == "DELETE":
|
||||
try:
|
||||
storage.set_bucket_cors(bucket_name, None)
|
||||
except StorageError as exc:
|
||||
return _error_response("NoSuchBucket", str(exc), 404)
|
||||
current_app.logger.info("Bucket CORS deleted", extra={"bucket": bucket_name})
|
||||
return Response(status=204)
|
||||
# PUT
|
||||
payload = request.get_data(cache=False) or b""
|
||||
if not payload.strip():
|
||||
try:
|
||||
@@ -907,6 +1075,66 @@ def _bucket_encryption_handler(bucket_name: str) -> Response:
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
def _bucket_location_handler(bucket_name: str) -> Response:
|
||||
if request.method != "GET":
|
||||
return _method_not_allowed(["GET"])
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
_authorize_action(principal, bucket_name, "list")
|
||||
except IamError as exc:
|
||||
return _error_response("AccessDenied", str(exc), 403)
|
||||
storage = _storage()
|
||||
if not storage.bucket_exists(bucket_name):
|
||||
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
||||
|
||||
# Return the configured AWS_REGION
|
||||
region = current_app.config.get("AWS_REGION", "us-east-1")
|
||||
root = Element("LocationConstraint")
|
||||
# AWS returns empty for us-east-1, but we'll be explicit
|
||||
root.text = region if region != "us-east-1" else None
|
||||
return _xml_response(root)
|
||||
|
||||
|
||||
def _bucket_acl_handler(bucket_name: str) -> Response:
|
||||
if request.method not in {"GET", "PUT"}:
|
||||
return _method_not_allowed(["GET", "PUT"])
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
_authorize_action(principal, bucket_name, "policy")
|
||||
except IamError as exc:
|
||||
return _error_response("AccessDenied", str(exc), 403)
|
||||
storage = _storage()
|
||||
if not storage.bucket_exists(bucket_name):
|
||||
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
||||
|
||||
if request.method == "PUT":
|
||||
# We don't fully implement ACLs, but we accept the request for compatibility
|
||||
# Check for canned ACL header
|
||||
canned_acl = request.headers.get("x-amz-acl", "private")
|
||||
current_app.logger.info("Bucket ACL set (canned)", extra={"bucket": bucket_name, "acl": canned_acl})
|
||||
return Response(status=200)
|
||||
|
||||
# GET - Return a basic ACL document showing full control for owner
|
||||
root = Element("AccessControlPolicy")
|
||||
owner = SubElement(root, "Owner")
|
||||
SubElement(owner, "ID").text = principal.access_key if principal else "anonymous"
|
||||
SubElement(owner, "DisplayName").text = principal.display_name if principal else "Anonymous"
|
||||
|
||||
acl = SubElement(root, "AccessControlList")
|
||||
grant = SubElement(acl, "Grant")
|
||||
grantee = SubElement(grant, "Grantee")
|
||||
grantee.set("{http://www.w3.org/2001/XMLSchema-instance}type", "CanonicalUser")
|
||||
SubElement(grantee, "ID").text = principal.access_key if principal else "anonymous"
|
||||
SubElement(grantee, "DisplayName").text = principal.display_name if principal else "Anonymous"
|
||||
SubElement(grant, "Permission").text = "FULL_CONTROL"
|
||||
|
||||
return _xml_response(root)
|
||||
|
||||
|
||||
def _bulk_delete_handler(bucket_name: str) -> Response:
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
@@ -1067,7 +1295,7 @@ def bucket_handler(bucket_name: str) -> Response:
|
||||
current_app.logger.info("Bucket deleted", extra={"bucket": bucket_name})
|
||||
return Response(status=204)
|
||||
|
||||
# GET - list objects
|
||||
# GET - list objects (supports both ListObjects and ListObjectsV2)
|
||||
principal, error = _require_principal()
|
||||
try:
|
||||
_authorize_action(principal, bucket_name, "list")
|
||||
@@ -1080,16 +1308,131 @@ def bucket_handler(bucket_name: str) -> Response:
|
||||
except StorageError as exc:
|
||||
return _error_response("NoSuchBucket", str(exc), 404)
|
||||
|
||||
root = Element("ListBucketResult")
|
||||
SubElement(root, "Name").text = bucket_name
|
||||
SubElement(root, "MaxKeys").text = str(current_app.config["UI_PAGE_SIZE"])
|
||||
SubElement(root, "IsTruncated").text = "false"
|
||||
for meta in objects:
|
||||
obj_el = SubElement(root, "Contents")
|
||||
SubElement(obj_el, "Key").text = meta.key
|
||||
SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat()
|
||||
SubElement(obj_el, "ETag").text = f'"{meta.etag}"'
|
||||
SubElement(obj_el, "Size").text = str(meta.size)
|
||||
# Check if this is ListObjectsV2 (list-type=2)
|
||||
list_type = request.args.get("list-type")
|
||||
prefix = request.args.get("prefix", "")
|
||||
delimiter = request.args.get("delimiter", "")
|
||||
max_keys = min(int(request.args.get("max-keys", current_app.config["UI_PAGE_SIZE"])), 1000)
|
||||
|
||||
# Pagination markers
|
||||
marker = request.args.get("marker", "") # ListObjects v1
|
||||
continuation_token = request.args.get("continuation-token", "") # ListObjectsV2
|
||||
start_after = request.args.get("start-after", "") # ListObjectsV2
|
||||
|
||||
# For ListObjectsV2, continuation-token takes precedence, then start-after
|
||||
# For ListObjects v1, use marker
|
||||
effective_start = ""
|
||||
if list_type == "2":
|
||||
if continuation_token:
|
||||
import base64
|
||||
try:
|
||||
effective_start = base64.urlsafe_b64decode(continuation_token.encode()).decode("utf-8")
|
||||
except Exception:
|
||||
effective_start = continuation_token
|
||||
elif start_after:
|
||||
effective_start = start_after
|
||||
else:
|
||||
effective_start = marker
|
||||
|
||||
if prefix:
|
||||
objects = [obj for obj in objects if obj.key.startswith(prefix)]
|
||||
|
||||
if effective_start:
|
||||
objects = [obj for obj in objects if obj.key > effective_start]
|
||||
|
||||
common_prefixes: list[str] = []
|
||||
filtered_objects: list = []
|
||||
if delimiter:
|
||||
seen_prefixes: set[str] = set()
|
||||
for obj in objects:
|
||||
key_after_prefix = obj.key[len(prefix):] if prefix else obj.key
|
||||
if delimiter in key_after_prefix:
|
||||
# This is a "folder" - extract the common prefix
|
||||
common_prefix = prefix + key_after_prefix.split(delimiter)[0] + delimiter
|
||||
if common_prefix not in seen_prefixes:
|
||||
seen_prefixes.add(common_prefix)
|
||||
common_prefixes.append(common_prefix)
|
||||
else:
|
||||
filtered_objects.append(obj)
|
||||
objects = filtered_objects
|
||||
common_prefixes = sorted(common_prefixes)
|
||||
|
||||
total_items = len(objects) + len(common_prefixes)
|
||||
is_truncated = total_items > max_keys
|
||||
|
||||
if len(objects) >= max_keys:
|
||||
objects = objects[:max_keys]
|
||||
common_prefixes = []
|
||||
else:
|
||||
remaining = max_keys - len(objects)
|
||||
common_prefixes = common_prefixes[:remaining]
|
||||
|
||||
next_marker = ""
|
||||
next_continuation_token = ""
|
||||
if is_truncated:
|
||||
if objects:
|
||||
next_marker = objects[-1].key
|
||||
elif common_prefixes:
|
||||
next_marker = common_prefixes[-1].rstrip(delimiter) if delimiter else common_prefixes[-1]
|
||||
|
||||
if list_type == "2" and next_marker:
|
||||
import base64
|
||||
next_continuation_token = base64.urlsafe_b64encode(next_marker.encode()).decode("utf-8")
|
||||
|
||||
if list_type == "2":
|
||||
root = Element("ListBucketResult")
|
||||
SubElement(root, "Name").text = bucket_name
|
||||
SubElement(root, "Prefix").text = prefix
|
||||
SubElement(root, "MaxKeys").text = str(max_keys)
|
||||
SubElement(root, "KeyCount").text = str(len(objects) + len(common_prefixes))
|
||||
SubElement(root, "IsTruncated").text = "true" if is_truncated else "false"
|
||||
if delimiter:
|
||||
SubElement(root, "Delimiter").text = delimiter
|
||||
|
||||
continuation_token = request.args.get("continuation-token", "")
|
||||
start_after = request.args.get("start-after", "")
|
||||
if continuation_token:
|
||||
SubElement(root, "ContinuationToken").text = continuation_token
|
||||
if start_after:
|
||||
SubElement(root, "StartAfter").text = start_after
|
||||
|
||||
if is_truncated and next_continuation_token:
|
||||
SubElement(root, "NextContinuationToken").text = next_continuation_token
|
||||
|
||||
for meta in objects:
|
||||
obj_el = SubElement(root, "Contents")
|
||||
SubElement(obj_el, "Key").text = meta.key
|
||||
SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat()
|
||||
SubElement(obj_el, "ETag").text = f'"{meta.etag}"'
|
||||
SubElement(obj_el, "Size").text = str(meta.size)
|
||||
SubElement(obj_el, "StorageClass").text = "STANDARD"
|
||||
|
||||
for cp in common_prefixes:
|
||||
cp_el = SubElement(root, "CommonPrefixes")
|
||||
SubElement(cp_el, "Prefix").text = cp
|
||||
else:
|
||||
root = Element("ListBucketResult")
|
||||
SubElement(root, "Name").text = bucket_name
|
||||
SubElement(root, "Prefix").text = prefix
|
||||
SubElement(root, "Marker").text = marker
|
||||
SubElement(root, "MaxKeys").text = str(max_keys)
|
||||
SubElement(root, "IsTruncated").text = "true" if is_truncated else "false"
|
||||
if delimiter:
|
||||
SubElement(root, "Delimiter").text = delimiter
|
||||
|
||||
if is_truncated and delimiter and next_marker:
|
||||
SubElement(root, "NextMarker").text = next_marker
|
||||
|
||||
for meta in objects:
|
||||
obj_el = SubElement(root, "Contents")
|
||||
SubElement(obj_el, "Key").text = meta.key
|
||||
SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat()
|
||||
SubElement(obj_el, "ETag").text = f'"{meta.etag}"'
|
||||
SubElement(obj_el, "Size").text = str(meta.size)
|
||||
|
||||
for cp in common_prefixes:
|
||||
cp_el = SubElement(root, "CommonPrefixes")
|
||||
SubElement(cp_el, "Prefix").text = cp
|
||||
|
||||
return _xml_response(root)
|
||||
|
||||
@@ -1099,6 +1442,9 @@ def bucket_handler(bucket_name: str) -> Response:
|
||||
def object_handler(bucket_name: str, object_key: str):
|
||||
storage = _storage()
|
||||
|
||||
if "tagging" in request.args:
|
||||
return _object_tagging_handler(bucket_name, object_key)
|
||||
|
||||
# Multipart Uploads
|
||||
if request.method == "POST":
|
||||
if "uploads" in request.args:
|
||||
@@ -1111,6 +1457,10 @@ def object_handler(bucket_name: str, object_key: str):
|
||||
if "partNumber" in request.args and "uploadId" in request.args:
|
||||
return _upload_part(bucket_name, object_key)
|
||||
|
||||
copy_source = request.headers.get("x-amz-copy-source")
|
||||
if copy_source:
|
||||
return _copy_object(bucket_name, object_key, copy_source)
|
||||
|
||||
_, error = _object_principal("write", bucket_name, object_key)
|
||||
if error:
|
||||
return error
|
||||
@@ -1121,6 +1471,12 @@ def object_handler(bucket_name: str, object_key: str):
|
||||
stream = AwsChunkedDecoder(stream)
|
||||
|
||||
metadata = _extract_request_metadata()
|
||||
|
||||
content_type = request.headers.get("Content-Type")
|
||||
validation_error = _validate_content_type(object_key, content_type)
|
||||
if validation_error:
|
||||
return _error_response("InvalidArgument", validation_error, 400)
|
||||
|
||||
try:
|
||||
meta = storage.put_object(
|
||||
bucket_name,
|
||||
@@ -1357,6 +1713,88 @@ def head_object(bucket_name: str, object_key: str) -> Response:
|
||||
return _error_response("AccessDenied", str(exc), 403)
|
||||
|
||||
|
||||
def _copy_object(dest_bucket: str, dest_key: str, copy_source: str) -> Response:
|
||||
"""Handle S3 CopyObject operation."""
|
||||
from urllib.parse import unquote
|
||||
copy_source = unquote(copy_source)
|
||||
if copy_source.startswith("/"):
|
||||
copy_source = copy_source[1:]
|
||||
|
||||
parts = copy_source.split("/", 1)
|
||||
if len(parts) != 2:
|
||||
return _error_response("InvalidArgument", "Invalid x-amz-copy-source format", 400)
|
||||
|
||||
source_bucket, source_key = parts
|
||||
if not source_bucket or not source_key:
|
||||
return _error_response("InvalidArgument", "Invalid x-amz-copy-source format", 400)
|
||||
|
||||
principal, error = _require_principal()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
_authorize_action(principal, source_bucket, "read", object_key=source_key)
|
||||
except IamError as exc:
|
||||
return _error_response("AccessDenied", str(exc), 403)
|
||||
|
||||
try:
|
||||
_authorize_action(principal, dest_bucket, "write", object_key=dest_key)
|
||||
except IamError as exc:
|
||||
return _error_response("AccessDenied", str(exc), 403)
|
||||
|
||||
storage = _storage()
|
||||
|
||||
try:
|
||||
source_path = storage.get_object_path(source_bucket, source_key)
|
||||
except StorageError:
|
||||
return _error_response("NoSuchKey", "Source object not found", 404)
|
||||
|
||||
source_metadata = storage.get_object_metadata(source_bucket, source_key)
|
||||
|
||||
metadata_directive = request.headers.get("x-amz-metadata-directive", "COPY").upper()
|
||||
if metadata_directive == "REPLACE":
|
||||
metadata = _extract_request_metadata()
|
||||
content_type = request.headers.get("Content-Type")
|
||||
validation_error = _validate_content_type(dest_key, content_type)
|
||||
if validation_error:
|
||||
return _error_response("InvalidArgument", validation_error, 400)
|
||||
else:
|
||||
metadata = source_metadata
|
||||
|
||||
try:
|
||||
with source_path.open("rb") as stream:
|
||||
meta = storage.put_object(
|
||||
dest_bucket,
|
||||
dest_key,
|
||||
stream,
|
||||
metadata=metadata or None,
|
||||
)
|
||||
except StorageError as exc:
|
||||
message = str(exc)
|
||||
if "Bucket" in message:
|
||||
return _error_response("NoSuchBucket", message, 404)
|
||||
return _error_response("InvalidArgument", message, 400)
|
||||
|
||||
current_app.logger.info(
|
||||
"Object copied",
|
||||
extra={
|
||||
"source_bucket": source_bucket,
|
||||
"source_key": source_key,
|
||||
"dest_bucket": dest_bucket,
|
||||
"dest_key": dest_key,
|
||||
"size": meta.size,
|
||||
},
|
||||
)
|
||||
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
if "S3ReplicationAgent" not in user_agent:
|
||||
_replication_manager().trigger_replication(dest_bucket, dest_key, action="write")
|
||||
|
||||
root = Element("CopyObjectResult")
|
||||
SubElement(root, "LastModified").text = meta.last_modified.isoformat()
|
||||
SubElement(root, "ETag").text = f'"{meta.etag}"'
|
||||
return _xml_response(root)
|
||||
|
||||
|
||||
class AwsChunkedDecoder:
|
||||
"""Decodes aws-chunked encoded streams."""
|
||||
def __init__(self, stream):
|
||||
@@ -1389,12 +1827,11 @@ class AwsChunkedDecoder:
|
||||
if crlf != b"\r\n":
|
||||
raise IOError("Malformed chunk: missing CRLF")
|
||||
else:
|
||||
# Read chunk size line
|
||||
line = b""
|
||||
while True:
|
||||
char = self.stream.read(1)
|
||||
if not char:
|
||||
if not line: # EOF at start of chunk size
|
||||
if not line:
|
||||
self.finished = True
|
||||
return result
|
||||
raise IOError("Unexpected EOF in chunk size")
|
||||
@@ -1402,7 +1839,6 @@ class AwsChunkedDecoder:
|
||||
if line.endswith(b"\r\n"):
|
||||
break
|
||||
|
||||
# Parse chunk size (hex)
|
||||
try:
|
||||
line_str = line.decode("ascii").strip()
|
||||
# Handle chunk-signature extension if present (e.g. "1000;chunk-signature=...")
|
||||
@@ -1414,7 +1850,6 @@ class AwsChunkedDecoder:
|
||||
|
||||
if chunk_size == 0:
|
||||
self.finished = True
|
||||
# Read trailers if any (until empty line)
|
||||
while True:
|
||||
line = b""
|
||||
while True:
|
||||
@@ -1534,13 +1969,11 @@ def _complete_multipart_upload(bucket_name: str, object_key: str) -> Response:
|
||||
return _error_response("NoSuchUpload", str(exc), 404)
|
||||
return _error_response("InvalidPart", str(exc), 400)
|
||||
|
||||
# Trigger replication
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
if "S3ReplicationAgent" not in user_agent:
|
||||
_replication_manager().trigger_replication(bucket_name, object_key, action="write")
|
||||
|
||||
root = Element("CompleteMultipartUploadResult")
|
||||
# Use request.host_url to construct full location
|
||||
location = f"{request.host_url}{bucket_name}/{object_key}"
|
||||
SubElement(root, "Location").text = location
|
||||
SubElement(root, "Bucket").text = bucket_name
|
||||
|
||||
193
app/storage.py
193
app/storage.py
@@ -10,10 +10,40 @@ import stat
|
||||
import time
|
||||
import unicodedata
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, BinaryIO, Dict, List, Optional
|
||||
from typing import Any, BinaryIO, Dict, Generator, List, Optional
|
||||
|
||||
# Platform-specific file locking
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
@contextmanager
|
||||
def _file_lock(file_handle) -> Generator[None, None, None]:
|
||||
"""Acquire an exclusive lock on a file (Windows)."""
|
||||
try:
|
||||
msvcrt.locking(file_handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
file_handle.seek(0)
|
||||
msvcrt.locking(file_handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
import fcntl # type: ignore
|
||||
|
||||
@contextmanager
|
||||
def _file_lock(file_handle) -> Generator[None, None, None]:
|
||||
"""Acquire an exclusive lock on a file (Unix)."""
|
||||
try:
|
||||
fcntl.flock(file_handle.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
WINDOWS_RESERVED_NAMES = {
|
||||
"CON",
|
||||
@@ -119,8 +149,13 @@ class ObjectStorage:
|
||||
bucket_path.mkdir(parents=True, exist_ok=False)
|
||||
self._system_bucket_root(bucket_path.name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def bucket_stats(self, bucket_name: str) -> dict[str, int]:
|
||||
"""Return object count and total size for the bucket (cached)."""
|
||||
def bucket_stats(self, bucket_name: str, cache_ttl: int = 60) -> dict[str, int]:
|
||||
"""Return object count and total size for the bucket (cached).
|
||||
|
||||
Args:
|
||||
bucket_name: Name of the bucket
|
||||
cache_ttl: Cache time-to-live in seconds (default 60)
|
||||
"""
|
||||
bucket_path = self._bucket_path(bucket_name)
|
||||
if not bucket_path.exists():
|
||||
raise StorageError("Bucket does not exist")
|
||||
@@ -129,8 +164,8 @@ class ObjectStorage:
|
||||
cache_path = self._system_bucket_root(bucket_name) / "stats.json"
|
||||
if cache_path.exists():
|
||||
try:
|
||||
# Check if cache is fresh (e.g., < 60 seconds old)
|
||||
if time.time() - cache_path.stat().st_mtime < 60:
|
||||
# Check if cache is fresh
|
||||
if time.time() - cache_path.stat().st_mtime < cache_ttl:
|
||||
return json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
@@ -158,6 +193,14 @@ class ObjectStorage:
|
||||
|
||||
return stats
|
||||
|
||||
def _invalidate_bucket_stats_cache(self, bucket_id: str) -> None:
|
||||
"""Invalidate the cached bucket statistics."""
|
||||
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
||||
try:
|
||||
cache_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def delete_bucket(self, bucket_name: str) -> None:
|
||||
bucket_path = self._bucket_path(bucket_name)
|
||||
if not bucket_path.exists():
|
||||
@@ -228,6 +271,10 @@ class ObjectStorage:
|
||||
self._write_metadata(bucket_id, safe_key, metadata)
|
||||
else:
|
||||
self._delete_metadata(bucket_id, safe_key)
|
||||
|
||||
# Invalidate bucket stats cache
|
||||
self._invalidate_bucket_stats_cache(bucket_id)
|
||||
|
||||
return ObjectMeta(
|
||||
key=safe_key.as_posix(),
|
||||
size=stat.st_size,
|
||||
@@ -261,6 +308,10 @@ class ObjectStorage:
|
||||
rel = path.relative_to(bucket_path)
|
||||
self._safe_unlink(path)
|
||||
self._delete_metadata(bucket_id, rel)
|
||||
|
||||
# Invalidate bucket stats cache
|
||||
self._invalidate_bucket_stats_cache(bucket_id)
|
||||
|
||||
for parent in path.parents:
|
||||
if parent == bucket_path:
|
||||
break
|
||||
@@ -284,6 +335,10 @@ class ObjectStorage:
|
||||
legacy_version_dir = self._legacy_version_dir(bucket_id, rel)
|
||||
if legacy_version_dir.exists():
|
||||
shutil.rmtree(legacy_version_dir, ignore_errors=True)
|
||||
|
||||
# Invalidate bucket stats cache
|
||||
self._invalidate_bucket_stats_cache(bucket_id)
|
||||
|
||||
for parent in target.parents:
|
||||
if parent == bucket_path:
|
||||
break
|
||||
@@ -356,6 +411,74 @@ class ObjectStorage:
|
||||
bucket_path = self._require_bucket_path(bucket_name)
|
||||
self._set_bucket_config_entry(bucket_path.name, "encryption", config_payload or None)
|
||||
|
||||
# ---------------------- Object tagging helpers ----------------------
|
||||
def get_object_tags(self, bucket_name: str, object_key: str) -> List[Dict[str, str]]:
|
||||
"""Get tags for an object."""
|
||||
bucket_path = self._bucket_path(bucket_name)
|
||||
if not bucket_path.exists():
|
||||
raise StorageError("Bucket does not exist")
|
||||
safe_key = self._sanitize_object_key(object_key)
|
||||
object_path = bucket_path / safe_key
|
||||
if not object_path.exists():
|
||||
raise StorageError("Object does not exist")
|
||||
|
||||
# Tags are stored in the metadata file alongside user metadata
|
||||
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
|
||||
if not meta_file.exists():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
tags = payload.get("tags")
|
||||
if isinstance(tags, list):
|
||||
return tags
|
||||
return []
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
def set_object_tags(self, bucket_name: str, object_key: str, tags: Optional[List[Dict[str, str]]]) -> None:
|
||||
"""Set tags for an object."""
|
||||
bucket_path = self._bucket_path(bucket_name)
|
||||
if not bucket_path.exists():
|
||||
raise StorageError("Bucket does not exist")
|
||||
safe_key = self._sanitize_object_key(object_key)
|
||||
object_path = bucket_path / safe_key
|
||||
if not object_path.exists():
|
||||
raise StorageError("Object does not exist")
|
||||
|
||||
meta_file = self._metadata_file(bucket_path.name, safe_key)
|
||||
|
||||
# Read existing metadata
|
||||
existing_payload: Dict[str, Any] = {}
|
||||
if meta_file.exists():
|
||||
try:
|
||||
existing_payload = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
# Update tags
|
||||
if tags:
|
||||
existing_payload["tags"] = tags
|
||||
else:
|
||||
existing_payload.pop("tags", None)
|
||||
|
||||
# Write back if there's anything to store, otherwise delete
|
||||
if existing_payload.get("metadata") or existing_payload.get("tags"):
|
||||
meta_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
meta_file.write_text(json.dumps(existing_payload), encoding="utf-8")
|
||||
elif meta_file.exists():
|
||||
meta_file.unlink()
|
||||
# Clean up empty parent directories
|
||||
parent = meta_file.parent
|
||||
meta_root = self._bucket_meta_root(bucket_path.name)
|
||||
while parent != meta_root and parent.exists() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
parent = parent.parent
|
||||
|
||||
def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
|
||||
"""Delete all tags from an object."""
|
||||
self.set_object_tags(bucket_name, object_key, None)
|
||||
|
||||
def list_object_versions(self, bucket_name: str, object_key: str) -> List[Dict[str, Any]]:
|
||||
bucket_path = self._bucket_path(bucket_name)
|
||||
if not bucket_path.exists():
|
||||
@@ -571,29 +694,49 @@ class ObjectStorage:
|
||||
safe_key = self._sanitize_object_key(manifest["object_key"])
|
||||
destination = bucket_path / safe_key
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self._is_versioning_enabled(bucket_path) and destination.exists():
|
||||
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
||||
checksum = hashlib.md5()
|
||||
with destination.open("wb") as target:
|
||||
for _, record in validated:
|
||||
part_path = upload_root / record["filename"]
|
||||
if not part_path.exists():
|
||||
raise StorageError(f"Missing part file {record['filename']}")
|
||||
with part_path.open("rb") as chunk:
|
||||
while True:
|
||||
data = chunk.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
checksum.update(data)
|
||||
target.write(data)
|
||||
|
||||
metadata = manifest.get("metadata")
|
||||
if metadata:
|
||||
self._write_metadata(bucket_id, safe_key, metadata)
|
||||
else:
|
||||
self._delete_metadata(bucket_id, safe_key)
|
||||
# Use a lock file to prevent concurrent writes to the same destination
|
||||
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
|
||||
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
with lock_file_path.open("w") as lock_file:
|
||||
with _file_lock(lock_file):
|
||||
if self._is_versioning_enabled(bucket_path) and destination.exists():
|
||||
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
||||
checksum = hashlib.md5()
|
||||
with destination.open("wb") as target:
|
||||
for _, record in validated:
|
||||
part_path = upload_root / record["filename"]
|
||||
if not part_path.exists():
|
||||
raise StorageError(f"Missing part file {record['filename']}")
|
||||
with part_path.open("rb") as chunk:
|
||||
while True:
|
||||
data = chunk.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
checksum.update(data)
|
||||
target.write(data)
|
||||
|
||||
metadata = manifest.get("metadata")
|
||||
if metadata:
|
||||
self._write_metadata(bucket_id, safe_key, metadata)
|
||||
else:
|
||||
self._delete_metadata(bucket_id, safe_key)
|
||||
except BlockingIOError:
|
||||
raise StorageError("Another upload to this key is in progress")
|
||||
finally:
|
||||
# Clean up lock file
|
||||
try:
|
||||
lock_file_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
shutil.rmtree(upload_root, ignore_errors=True)
|
||||
|
||||
# Invalidate bucket stats cache
|
||||
self._invalidate_bucket_stats_cache(bucket_id)
|
||||
|
||||
stat = destination.stat()
|
||||
return ObjectMeta(
|
||||
key=safe_key.as_posix(),
|
||||
|
||||
52
app/ui.py
52
app/ui.py
@@ -249,7 +249,8 @@ def buckets_overview():
|
||||
if bucket.name not in allowed_names:
|
||||
continue
|
||||
policy = policy_store.get_policy(bucket.name)
|
||||
stats = _storage().bucket_stats(bucket.name)
|
||||
cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60)
|
||||
stats = _storage().bucket_stats(bucket.name, cache_ttl=cache_ttl)
|
||||
access_label, access_badge = _bucket_access_descriptor(policy)
|
||||
visible_buckets.append({
|
||||
"meta": bucket,
|
||||
@@ -335,7 +336,7 @@ def bucket_detail(bucket_name: str):
|
||||
except IamError:
|
||||
can_manage_versioning = False
|
||||
|
||||
# Replication info
|
||||
# Replication info - don't compute sync status here (it's slow), let JS fetch it async
|
||||
replication_rule = _replication().get_rule(bucket_name)
|
||||
connections = _connections().list()
|
||||
|
||||
@@ -1178,8 +1179,12 @@ def update_bucket_replication(bucket_name: str):
|
||||
_replication().delete_rule(bucket_name)
|
||||
flash("Replication disabled", "info")
|
||||
else:
|
||||
from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL
|
||||
import time
|
||||
|
||||
target_conn_id = request.form.get("target_connection_id")
|
||||
target_bucket = request.form.get("target_bucket", "").strip()
|
||||
replication_mode = request.form.get("replication_mode", REPLICATION_MODE_NEW_ONLY)
|
||||
|
||||
if not target_conn_id or not target_bucket:
|
||||
flash("Target connection and bucket are required", "danger")
|
||||
@@ -1188,14 +1193,50 @@ def update_bucket_replication(bucket_name: str):
|
||||
bucket_name=bucket_name,
|
||||
target_connection_id=target_conn_id,
|
||||
target_bucket=target_bucket,
|
||||
enabled=True
|
||||
enabled=True,
|
||||
mode=replication_mode,
|
||||
created_at=time.time(),
|
||||
)
|
||||
_replication().set_rule(rule)
|
||||
flash("Replication configured", "success")
|
||||
|
||||
# If mode is "all", trigger replication of existing objects
|
||||
if replication_mode == REPLICATION_MODE_ALL:
|
||||
_replication().replicate_existing_objects(bucket_name)
|
||||
flash("Replication configured. Existing objects are being replicated in the background.", "success")
|
||||
else:
|
||||
flash("Replication configured. Only new uploads will be replicated.", "success")
|
||||
|
||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||
|
||||
|
||||
@ui_bp.get("/buckets/<bucket_name>/replication/status")
|
||||
def get_replication_status(bucket_name: str):
|
||||
"""Async endpoint to fetch replication sync status without blocking page load."""
|
||||
principal = _current_principal()
|
||||
try:
|
||||
_authorize_ui(principal, bucket_name, "read")
|
||||
except IamError:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
|
||||
rule = _replication().get_rule(bucket_name)
|
||||
if not rule:
|
||||
return jsonify({"error": "No replication rule"}), 404
|
||||
|
||||
# This is the slow operation - compute sync status by comparing buckets
|
||||
stats = _replication().get_sync_status(bucket_name)
|
||||
if not stats:
|
||||
return jsonify({"error": "Failed to compute status"}), 500
|
||||
|
||||
return jsonify({
|
||||
"objects_synced": stats.objects_synced,
|
||||
"objects_pending": stats.objects_pending,
|
||||
"objects_orphaned": stats.objects_orphaned,
|
||||
"bytes_synced": stats.bytes_synced,
|
||||
"last_sync_at": stats.last_sync_at,
|
||||
"last_sync_key": stats.last_sync_key,
|
||||
})
|
||||
|
||||
|
||||
@ui_bp.get("/connections")
|
||||
def connections_dashboard():
|
||||
principal = _current_principal()
|
||||
@@ -1227,8 +1268,9 @@ def metrics_dashboard():
|
||||
total_bytes_used = 0
|
||||
|
||||
# Note: Uses cached stats from storage layer to improve performance
|
||||
cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60)
|
||||
for bucket in buckets:
|
||||
stats = storage.bucket_stats(bucket.name)
|
||||
stats = storage.bucket_stats(bucket.name, cache_ttl=cache_ttl)
|
||||
total_objects += stats["objects"]
|
||||
total_bytes_used += stats["bytes"]
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
--myfsio-policy-bg: #0f172a;
|
||||
--myfsio-policy-fg: #e2e8f0;
|
||||
--myfsio-hover-bg: rgba(59, 130, 246, 0.12);
|
||||
--myfsio-accent: #3b82f6;
|
||||
--myfsio-accent-hover: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
@@ -30,6 +32,8 @@
|
||||
--myfsio-policy-bg: #0f1419;
|
||||
--myfsio-policy-fg: #f8fafc;
|
||||
--myfsio-hover-bg: rgba(59, 130, 246, 0.2);
|
||||
--myfsio-accent: #60a5fa;
|
||||
--myfsio-accent-hover: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme='dark'] body,
|
||||
@@ -72,6 +76,7 @@ code {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] code {
|
||||
background-color: rgba(148, 163, 184, 0.15);
|
||||
color: #93c5fd;
|
||||
@@ -109,32 +114,31 @@ code {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Drag and Drop Zone */
|
||||
.drop-zone {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
background-color: var(--myfsio-hover-bg);
|
||||
border: 2px dashed var(--myfsio-input-border);
|
||||
background-color: var(--myfsio-hover-bg);
|
||||
border: 2px dashed var(--myfsio-input-border);
|
||||
}
|
||||
|
||||
.drop-zone.drag-over::after {
|
||||
content: 'Drop files here to upload';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--myfsio-muted);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
content: 'Drop files here to upload';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--myfsio-muted);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over table {
|
||||
opacity: 0.3;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
@@ -145,7 +149,10 @@ code {
|
||||
.myfsio-nav {
|
||||
background: var(--myfsio-nav-gradient);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.myfsio-nav .navbar-brand {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
@@ -154,33 +161,42 @@ code {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.myfsio-logo {
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: 0 0 6px rgba(15, 23, 42, 0.35);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.myfsio-title {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.myfsio-nav .nav-link {
|
||||
color: var(--myfsio-nav-link);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.myfsio-nav .nav-link:hover {
|
||||
color: var(--myfsio-nav-link-hover);
|
||||
}
|
||||
|
||||
.myfsio-nav .nav-link.nav-link-muted { opacity: 0.75; }
|
||||
|
||||
.myfsio-nav .nav-link.nav-link-muted .badge {
|
||||
color: #0f172a;
|
||||
background-color: #fef08a;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .myfsio-nav .nav-link.nav-link-muted .badge {
|
||||
color: #0f172a;
|
||||
background-color: #fde047;
|
||||
}
|
||||
|
||||
.myfsio-nav .navbar-toggler {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.myfsio-nav .navbar-toggler-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
@@ -329,6 +345,7 @@ code {
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
padding: 0.35em 0.65em;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
@@ -434,6 +451,22 @@ code {
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-dropzone::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.03) 0%, rgba(139, 92, 246, 0.03) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-dropzone:hover::before,
|
||||
.upload-dropzone.is-dragover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload-dropzone.is-dragover {
|
||||
@@ -655,6 +688,18 @@ code {
|
||||
|
||||
.btn-primary {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
box-shadow: 0 1px 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .btn-danger {
|
||||
@@ -716,17 +761,6 @@ code {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
|
||||
.config-copy {
|
||||
color: #ffffff;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.config-copy:hover {
|
||||
color: #0f172a;
|
||||
background-color: #ffffff;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .border {
|
||||
border-color: var(--myfsio-card-border) !important;
|
||||
}
|
||||
@@ -765,23 +799,6 @@ code {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.config-copy {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.config-copy:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
@@ -849,7 +866,6 @@ pre code {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* Breadcrumb styling */
|
||||
.breadcrumb {
|
||||
background-color: transparent;
|
||||
padding: 0.5rem 0;
|
||||
@@ -880,58 +896,684 @@ pre code {
|
||||
color: var(--myfsio-muted);
|
||||
}
|
||||
|
||||
/* Icon alignment */
|
||||
.bi {
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
|
||||
/* Sticky improvements */
|
||||
.sticky-top {
|
||||
top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Better card spacing */
|
||||
.card-body dl:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Empty state improvements */
|
||||
.text-center svg {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Input group improvements */
|
||||
[data-theme='dark'] .input-group .btn-outline-primary {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* File size nowrap */
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Alert improvements */
|
||||
.alert svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Better hover for table rows with data */
|
||||
[data-object-row]:hover {
|
||||
background-color: var(--myfsio-hover-bg) !important;
|
||||
}
|
||||
|
||||
/* Improve spacing in button groups */
|
||||
.btn-group-sm .btn {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Better modal styling */
|
||||
.modal-content {
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background-color: var(--myfsio-card-bg);
|
||||
border-bottom: 1px solid var(--myfsio-card-border);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header.border-0 {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
background-color: var(--myfsio-preview-bg);
|
||||
border-top: 1px solid var(--myfsio-card-border);
|
||||
padding: 1rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-footer.border-0 {
|
||||
border-top: none;
|
||||
background-color: var(--myfsio-card-bg);
|
||||
}
|
||||
|
||||
/* Badge improvements */
|
||||
.badge {
|
||||
[data-theme='dark'] .modal-footer {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .modal-footer.border-0 {
|
||||
background-color: var(--myfsio-card-bg);
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.modal-icon-danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.modal-icon-warning {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.modal-icon-success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.modal-icon-info {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .modal-icon-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .modal-icon-warning {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .modal-icon-success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .modal-icon-info {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.modal .alert {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.modal .form-control,
|
||||
.modal .form-select {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.modal .btn {
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal .btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.modal .list-group {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal .list-group-item {
|
||||
border-color: var(--myfsio-card-border);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--myfsio-preview-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--myfsio-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .connection-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.bucket-card {
|
||||
position: relative;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid var(--myfsio-card-border) !important;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem !important;
|
||||
}
|
||||
|
||||
.bucket-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.bucket-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.12), 0 4px 8px -4px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--myfsio-accent) !important;
|
||||
}
|
||||
|
||||
.bucket-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .bucket-card:hover {
|
||||
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bucket-card .card-body {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.bucket-card .card-footer {
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.bucket-card .bucket-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||
color: var(--myfsio-accent);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.bucket-card:hover .bucket-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .bucket-card .bucket-icon {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
|
||||
}
|
||||
|
||||
.bucket-card .bucket-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
background: var(--myfsio-preview-bg);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.bucket-card .bucket-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bucket-card .bucket-stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--myfsio-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bucket-card .bucket-stat-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--myfsio-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.bucket-card .bucket-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--myfsio-text);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bucket-card .bucket-access-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.35em 0.75em;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
.btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3), 0 0 0 1px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--myfsio-card-bg) 25%, var(--myfsio-hover-bg) 50%, var(--myfsio-card-bg) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
background: var(--myfsio-card-bg);
|
||||
border: 1px solid var(--myfsio-card-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
|
||||
border-radius: 50%;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .empty-state-icon {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metric-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, transparent 40%, rgba(59, 130, 246, 0.05) 100%);
|
||||
border-radius: 50%;
|
||||
transform: translate(30%, -30%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .metric-card::after {
|
||||
background: linear-gradient(135deg, transparent 40%, rgba(59, 130, 246, 0.1) 100%);
|
||||
}
|
||||
|
||||
.progress {
|
||||
overflow: visible;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover .icon-box {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input-wrapper .form-control {
|
||||
padding-left: 2.75rem;
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input-wrapper .form-control:focus {
|
||||
padding-left: 2.75rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper .search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--myfsio-muted);
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input-wrapper:focus-within .search-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: 2px solid var(--myfsio-card-border);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
border: none;
|
||||
color: var(--myfsio-muted);
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: var(--myfsio-text);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .nav-tabs .nav-link.active {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.file-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.file-type-image { background: rgba(139, 92, 246, 0.15); color: #7c3aed; }
|
||||
.file-type-video { background: rgba(236, 72, 153, 0.15); color: #db2777; }
|
||||
.file-type-audio { background: rgba(245, 158, 11, 0.15); color: #d97706; }
|
||||
.file-type-document { background: rgba(59, 130, 246, 0.15); color: #2563eb; }
|
||||
.file-type-archive { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
|
||||
.file-type-code { background: rgba(99, 102, 241, 0.15); color: #4f46e5; }
|
||||
|
||||
[data-theme='dark'] .file-type-image { background: rgba(139, 92, 246, 0.25); color: #a78bfa; }
|
||||
[data-theme='dark'] .file-type-video { background: rgba(236, 72, 153, 0.25); color: #f472b6; }
|
||||
[data-theme='dark'] .file-type-audio { background: rgba(245, 158, 11, 0.25); color: #fbbf24; }
|
||||
[data-theme='dark'] .file-type-document { background: rgba(59, 130, 246, 0.25); color: #60a5fa; }
|
||||
[data-theme='dark'] .file-type-archive { background: rgba(34, 197, 94, 0.25); color: #4ade80; }
|
||||
[data-theme='dark'] .file-type-code { background: rgba(99, 102, 241, 0.25); color: #818cf8; }
|
||||
|
||||
.table-hover [data-object-row] .btn-group {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.table-hover [data-object-row]:hover .btn-group {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--myfsio-body-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--myfsio-muted);
|
||||
border-radius: 4px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--myfsio-text);
|
||||
}
|
||||
|
||||
.modal.fade .modal-dialog {
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal.show .modal-dialog {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.tooltip-inner {
|
||||
background: var(--myfsio-policy-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
animation: countUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.action-btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn-group .btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: livePulse 2s infinite;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bucket-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.objects-table-container {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
position: relative !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-duration: 0s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
body.theme-transitioning,
|
||||
body.theme-transitioning * {
|
||||
transition-duration: 0.3s !important;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.status-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge-success .status-badge-dot { background: #22c55e; }
|
||||
.status-badge-warning .status-badge-dot { background: #f59e0b; }
|
||||
.status-badge-danger .status-badge-dot { background: #ef4444; }
|
||||
.status-badge-info .status-badge-dot { background: #3b82f6; }
|
||||
|
||||
.bucket-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--myfsio-card-bg);
|
||||
border: 1px solid var(--myfsio-card-border);
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bucket-list-item:hover {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
background: var(--myfsio-hover-bg);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.shadow-glow {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.border-gradient {
|
||||
border: 2px solid transparent;
|
||||
background: linear-gradient(var(--myfsio-card-bg), var(--myfsio-card-bg)) padding-box, linear-gradient(135deg, #3b82f6, #8b5cf6) border-box;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,48 +40,53 @@
|
||||
<div class="row g-3" id="buckets-container">
|
||||
{% for bucket in buckets %}
|
||||
<div class="col-md-6 col-xl-4 bucket-item">
|
||||
<div class="card h-100 shadow-sm border-0 bucket-card" data-bucket-row data-href="{{ bucket.detail_url }}">
|
||||
<div class="card h-100 shadow-sm bucket-card" data-bucket-row data-href="{{ bucket.detail_url }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-primary-subtle text-primary rounded p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-hdd-network" viewBox="0 0 16 16">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="bucket-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1V13.5a1.5 1.5 0 0 1 1.5-1.5h3V7H2a2 2 0 0 1-2-2V4zm1 0a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v1z"/>
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H11a.5.5 0 0 1 0 1h-1v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1H6v1a.5.5 0 0 1-1 0v-1H4a.5.5 0 0 1 0-1h1v-1H4a.5.5 0 0 1 0-1h1.5A1.5 1.5 0 0 1 7 10.5V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1zm5 7.5v1h3v-1a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 0-.5.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="card-title mb-0 text-break">{{ bucket.meta.name }}</h5>
|
||||
<div>
|
||||
<h5 class="bucket-name text-break">{{ bucket.meta.name }}</h5>
|
||||
<small class="text-muted">Created {{ bucket.meta.created_at.strftime('%b %d, %Y') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge {{ bucket.access_badge }} rounded-pill">{{ bucket.access_label }}</span>
|
||||
<span class="badge {{ bucket.access_badge }} bucket-access-badge">{{ bucket.access_label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-end mt-4">
|
||||
<div>
|
||||
<div class="text-muted small mb-1">Storage Used</div>
|
||||
<div class="fw-semibold">{{ bucket.summary.human_size }}</div>
|
||||
<div class="bucket-stats">
|
||||
<div class="bucket-stat">
|
||||
<div class="bucket-stat-value">{{ bucket.summary.human_size }}</div>
|
||||
<div class="bucket-stat-label">Storage</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="text-muted small mb-1">Objects</div>
|
||||
<div class="fw-semibold">{{ bucket.summary.objects }}</div>
|
||||
<div class="bucket-stat">
|
||||
<div class="bucket-stat-value">{{ bucket.summary.objects }}</div>
|
||||
<div class="bucket-stat-label">Objects</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0 pb-3">
|
||||
<small class="text-muted">Created {{ bucket.meta.created_at.strftime('%b %d, %Y') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5 bg-panel rounded-3 border border-dashed">
|
||||
<div class="mb-3 text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-bucket" viewBox="0 0 16 16">
|
||||
<div class="empty-state bg-panel rounded-3 border border-dashed">
|
||||
<div class="empty-state-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="currentColor" 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>
|
||||
</div>
|
||||
<h5>No buckets found</h5>
|
||||
<p class="text-muted mb-4">Get started by creating your first storage bucket.</p>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createBucketModal">Create Bucket</button>
|
||||
<h5 class="mb-2">No buckets yet</h5>
|
||||
<p class="text-muted mb-4">Create your first storage bucket to start organizing your files.</p>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createBucketModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Create Bucket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -90,20 +95,31 @@
|
||||
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">Create bucket</h1>
|
||||
<div class="modal-header border-0">
|
||||
<h1 class="modal-title fs-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
||||
</svg>
|
||||
Create bucket
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('ui.create_bucket') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-body">
|
||||
<label class="form-label">Bucket name</label>
|
||||
<input class="form-control" type="text" name="bucket_name" pattern="[a-z0-9.-]{3,63}" placeholder="team-assets" required />
|
||||
<div class="form-text">Must be 3-63 chars, lowercase letters, numbers, dots, or hyphens.</div>
|
||||
<div class="modal-body pt-0">
|
||||
<label class="form-label fw-medium">Bucket name</label>
|
||||
<input class="form-control" type="text" name="bucket_name" pattern="[a-z0-9.-]{3,63}" placeholder="my-bucket-name" required autofocus />
|
||||
<div class="form-text">Use 3-63 characters: lowercase letters, numbers, dots, or hyphens.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-primary" type="submit">Create</button>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,85 +3,136 @@
|
||||
{% block title %}Connections - S3 Compatible Storage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<h2>Remote Connections</h2>
|
||||
<p class="text-muted">Manage connections to other S3-compatible services for replication.</p>
|
||||
</div>
|
||||
<div class="page-header d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<p class="text-uppercase text-muted small mb-1">Replication</p>
|
||||
<h1 class="h3 mb-1 d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H12a.5.5 0 0 1 0 1H4a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z"/>
|
||||
</svg>
|
||||
Remote Connections
|
||||
</h1>
|
||||
<p class="text-muted mb-0 mt-1">Manage connections to other S3-compatible services for replication.</p>
|
||||
</div>
|
||||
<div class="d-none d-md-block">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
|
||||
{{ connections|length }} connection{{ 's' if connections|length != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">
|
||||
Add New Connection
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-4 col-md-5">
|
||||
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
|
||||
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Add New Connection
|
||||
</h5>
|
||||
<p class="text-muted small mb-0">Connect to an S3-compatible endpoint</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body px-4 pb-4">
|
||||
<form method="POST" action="{{ url_for('ui.create_connection') }}" id="createConnectionForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Production Backup">
|
||||
<label for="name" class="form-label fw-medium">Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required placeholder="Production Backup">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="endpoint_url" class="form-label">Endpoint URL</label>
|
||||
<label for="endpoint_url" class="form-label fw-medium">Endpoint URL</label>
|
||||
<input type="url" class="form-control" id="endpoint_url" name="endpoint_url" required placeholder="https://s3.us-east-1.amazonaws.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="region" class="form-label">Region</label>
|
||||
<label for="region" class="form-label fw-medium">Region</label>
|
||||
<input type="text" class="form-control" id="region" name="region" value="us-east-1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="access_key" class="form-label">Access Key</label>
|
||||
<input type="text" class="form-control" id="access_key" name="access_key" required>
|
||||
<label for="access_key" class="form-label fw-medium">Access Key</label>
|
||||
<input type="text" class="form-control font-monospace" id="access_key" name="access_key" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="secret_key" class="form-label">Secret Key</label>
|
||||
<label for="secret_key" class="form-label fw-medium">Secret Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="secret_key" name="secret_key" required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('secret_key')">
|
||||
<i class="bi bi-eye"></i> Show
|
||||
<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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
|
||||
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="testResult" class="mb-3"></div>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" id="testConnectionBtn">Test Connection</button>
|
||||
<button type="submit" class="btn btn-primary">Add Connection</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="testConnectionBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
||||
</svg>
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Add Connection
|
||||
</button>
|
||||
</div>
|
||||
<div id="testResult" class="mt-2"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">
|
||||
Existing Connections
|
||||
<div class="col-lg-8 col-md-7">
|
||||
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path d="M0 1.5A1.5 1.5 0 0 1 1.5 0h2A1.5 1.5 0 0 1 5 1.5v2A1.5 1.5 0 0 1 3.5 5h-2A1.5 1.5 0 0 1 0 3.5v-2zM1.5 1a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5h-2zM0 8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8zm1 3v2a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2H1zm14-1V8a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2h14zM2 8.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0 4a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5z"/>
|
||||
</svg>
|
||||
Existing Connections
|
||||
</h5>
|
||||
<p class="text-muted small mb-0">Configured remote endpoints</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body px-4 pb-4">
|
||||
{% if connections %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Region</th>
|
||||
<th>Access Key</th>
|
||||
<th class="text-end">Actions</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>
|
||||
{% for conn in connections %}
|
||||
<tr>
|
||||
<td class="fw-medium">{{ conn.name }}</td>
|
||||
<td class="small text-muted">{{ conn.endpoint_url }}</td>
|
||||
<td><span class="badge bg-light text-dark border">{{ conn.region }}</span></td>
|
||||
<td><code class="small">{{ conn.access_key }}</code></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.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H12a.5.5 0 0 1 0 1H4a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="fw-medium">{{ conn.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small text-truncate d-inline-block" style="max-width: 200px;" title="{{ conn.endpoint_url }}">{{ conn.endpoint_url }}</span>
|
||||
</td>
|
||||
<td><span class="badge bg-primary bg-opacity-10 text-primary">{{ conn.region }}</span></td>
|
||||
<td><code class="small">{{ conn.access_key[:8] }}...{{ conn.access_key[-4:] }}</code></td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
<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="{{ conn.id }}"
|
||||
@@ -89,15 +140,22 @@
|
||||
data-endpoint="{{ conn.endpoint_url }}"
|
||||
data-region="{{ conn.region }}"
|
||||
data-access="{{ conn.access_key }}"
|
||||
data-secret="{{ conn.secret_key }}">
|
||||
Edit
|
||||
data-secret="{{ 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-sm btn-outline-danger"
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteConnectionModal"
|
||||
data-id="{{ conn.id }}"
|
||||
data-name="{{ conn.name }}">
|
||||
Delete
|
||||
data-name="{{ 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>
|
||||
@@ -107,12 +165,15 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-hdd-network mb-3" viewBox="0 0 16 16">
|
||||
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5v3.375a1.125 1.125 0 0 1-1.125 1.125h-1.75a1.125 1.125 0 0 1-1.125-1.125V11.5A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1zm6 5v1.5a.5.5 0 0 0 .5.5h1.75a.5.5 0 0 0 .5-.5V10a.5.5 0 0 0-.5-.5H7.5a.5.5 0 0 0-.5.5z"/>
|
||||
</svg>
|
||||
<p>No remote connections configured.</p>
|
||||
<div class="empty-state text-center py-5">
|
||||
<div class="empty-state-icon mx-auto mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H12a.5.5 0 0 1 0 1H4a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="fw-semibold mb-2">No connections yet</h5>
|
||||
<p class="text-muted mb-0">Add your first remote connection to enable bucket replication.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -122,46 +183,64 @@
|
||||
|
||||
<!-- Edit Connection Modal -->
|
||||
<div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Connection</h5>
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" 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.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
Edit Connection
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="POST" id="editConnectionForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="edit_name" class="form-label">Name</label>
|
||||
<label for="edit_name" class="form-label fw-medium">Name</label>
|
||||
<input type="text" class="form-control" id="edit_name" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit_endpoint_url" class="form-label">Endpoint URL</label>
|
||||
<label for="edit_endpoint_url" class="form-label fw-medium">Endpoint URL</label>
|
||||
<input type="url" class="form-control" id="edit_endpoint_url" name="endpoint_url" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit_region" class="form-label">Region</label>
|
||||
<label for="edit_region" class="form-label fw-medium">Region</label>
|
||||
<input type="text" class="form-control" id="edit_region" name="region" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit_access_key" class="form-label">Access Key</label>
|
||||
<input type="text" class="form-control" id="edit_access_key" name="access_key" required>
|
||||
<label for="edit_access_key" class="form-label fw-medium">Access Key</label>
|
||||
<input type="text" class="form-control font-monospace" id="edit_access_key" name="access_key" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit_secret_key" class="form-label">Secret Key</label>
|
||||
<label for="edit_secret_key" class="form-label fw-medium">Secret Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" 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')">
|
||||
Show
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
|
||||
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editTestResult" class="mt-2"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" id="editTestConnectionBtn">Test Connection</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="editTestConnectionBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
||||
</svg>
|
||||
Test
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -172,19 +251,36 @@
|
||||
<div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Connection</h5>
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete Connection
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the connection <strong id="deleteConnectionName"></strong>?</p>
|
||||
<p class="text-muted small">This will stop any replication rules using this connection.</p>
|
||||
<p>Are you sure you want to delete <strong id="deleteConnectionName"></strong>?</p>
|
||||
<div class="alert alert-warning d-flex align-items-start small" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-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>This will stop any replication rules using this connection.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="POST" id="deleteConnectionForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<div class="page-header d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<p class="text-uppercase text-muted small mb-1">Identity & Access Management</p>
|
||||
<h1 class="h3 mb-1">IAM Configuration</h1>
|
||||
<h1 class="h3 mb-1 d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" 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>
|
||||
IAM Configuration
|
||||
</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if not iam_locked %}
|
||||
@@ -79,78 +84,121 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-body d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Users</span>
|
||||
{% if iam_locked %}<span class="badge text-bg-warning">View only</span>{% endif %}
|
||||
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
|
||||
</svg>
|
||||
Users
|
||||
</h5>
|
||||
<p class="text-muted small mb-0">{{ users|length if not iam_locked else '?' }} user{{ 's' if (users|length if not iam_locked else 0) != 1 else '' }} configured</p>
|
||||
</div>
|
||||
{% if iam_locked %}<span class="badge bg-warning bg-opacity-10 text-warning">View only</span>{% endif %}
|
||||
</div>
|
||||
{% if iam_locked %}
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-0">Sign in with an administrator to list or edit IAM users.</p>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<div class="alert alert-secondary d-flex align-items-center mb-0" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<div>Sign in with an administrator account to list or edit IAM users.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">Access Key</th>
|
||||
<th scope="col">Display Name</th>
|
||||
<th scope="col">Policies</th>
|
||||
<th scope="col" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td class="font-monospace">{{ user.access_key }}</td>
|
||||
<td>{{ user.display_name }}</td>
|
||||
<td>
|
||||
{% for policy in user.policies %}
|
||||
<span class="badge text-bg-light border text-dark mb-1">
|
||||
{{ policy.bucket }}
|
||||
{% if '*' in policy.actions %}
|
||||
<span class="text-muted">(*)</span>
|
||||
<div class="card-body px-4 pb-4">
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">User</th>
|
||||
<th scope="col">Policies</th>
|
||||
<th scope="col" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="user-avatar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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>
|
||||
<div class="fw-medium">{{ user.display_name }}</div>
|
||||
<code class="small text-muted">{{ user.access_key }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for policy in user.policies %}
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary">
|
||||
{{ policy.bucket }}
|
||||
{% if '*' in policy.actions %}
|
||||
<span class="opacity-75">(full)</span>
|
||||
{% else %}
|
||||
<span class="opacity-75">({{ policy.actions|length }})</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">({{ policy.actions|length }})</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-primary" type="button" data-rotate-user="{{ user.access_key }}" title="Rotate Secret">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-arrow-repeat" 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>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-edit-user="{{ user.access_key }}" data-display-name="{{ user.display_name }}" title="Edit User">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil" 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.5zm-9.761 5.175-.106.378.378-.106 5-5-.378-.378-5 5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-policy-editor data-access-key="{{ user.access_key }}" title="Edit Policies">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil-square" viewBox="0 0 16 16">
|
||||
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
|
||||
<path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" type="button" data-delete-user="{{ user.access_key }}" title="Delete User">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash" 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>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">No IAM users defined.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-primary" type="button" data-rotate-user="{{ user.access_key }}" title="Rotate Secret">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" 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>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-edit-user="{{ user.access_key }}" data-display-name="{{ user.display_name }}" title="Edit User">
|
||||
<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 class="btn btn-outline-secondary" type="button" data-policy-editor data-access-key="{{ user.access_key }}" title="Edit Policies">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" 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>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" type="button" data-delete-user="{{ user.access_key }}" title="Delete User">
|
||||
<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 .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>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state text-center py-5">
|
||||
<div class="empty-state-icon mx-auto mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="fw-semibold mb-2">No users yet</h5>
|
||||
<p class="text-muted mb-3">Create your first IAM user to manage access to your storage.</p>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createUserModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Create First User
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -159,28 +207,45 @@
|
||||
<div class="modal fade" id="createUserModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">Create IAM User</h1>
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h1 class="modal-title fs-5 fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
|
||||
<path fill-rule="evenodd" d="M13.5 5a.5.5 0 0 1 .5.5V7h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V8h-1.5a.5.5 0 0 1 0-1H13V5.5a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
Create IAM User
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('ui.create_iam_user') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input class="form-control" type="text" name="display_name" placeholder="Analytics Team" required />
|
||||
<label class="form-label fw-medium">Display Name</label>
|
||||
<input class="form-control" type="text" name="display_name" placeholder="Analytics Team" required autofocus />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Initial Policies (JSON)</label>
|
||||
<textarea class="form-control font-monospace" name="policies" rows="6" spellcheck="false" placeholder='[
|
||||
<label class="form-label fw-medium">Initial Policies (JSON)</label>
|
||||
<textarea class="form-control font-monospace" name="policies" id="createUserPolicies" rows="6" spellcheck="false" placeholder='[
|
||||
{"bucket": "*", "actions": ["list", "read"]}
|
||||
]'></textarea>
|
||||
<div class="form-text">Leave blank to grant full control (for bootstrap admins only).</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<span class="text-muted small me-2 align-self-center">Quick templates:</span>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="full">Full Control</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="readonly">Read-Only</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="writer">Read + Write</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-primary" type="submit">Create User</button>
|
||||
<button class="btn btn-primary" 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 fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -191,11 +256,18 @@
|
||||
<div class="modal fade" id="policyEditorModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">Edit Policies: <span id="policyEditorUserLabel" class="font-monospace"></span></h1>
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h1 class="modal-title fs-5 fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" 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-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
|
||||
</svg>
|
||||
Edit Policies
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-3">Editing policies for <code id="policyEditorUserLabel"></code></p>
|
||||
<form
|
||||
id="policyEditorForm"
|
||||
method="post"
|
||||
@@ -206,11 +278,12 @@
|
||||
<input type="hidden" id="policyEditorUser" name="access_key" />
|
||||
|
||||
<div>
|
||||
<label class="form-label">Inline Policies (JSON array)</label>
|
||||
<label class="form-label fw-medium">Inline Policies (JSON array)</label>
|
||||
<textarea class="form-control font-monospace" id="policyEditorDocument" name="policies" rows="12" spellcheck="false"></textarea>
|
||||
<div class="form-text">Use standard MyFSIO policy format. Validation happens server-side.</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<span class="text-muted small me-2 align-self-center">Quick templates:</span>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="full">Full Control</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="readonly">Read-Only</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="writer">Read + Write</button>
|
||||
@@ -219,7 +292,12 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-primary" type="submit" form="policyEditorForm">Save Policies</button>
|
||||
<button class="btn btn-primary" type="submit" form="policyEditorForm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
Save Policies
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,21 +307,31 @@
|
||||
<div class="modal fade" id="editUserModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">Edit User</h1>
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h1 class="modal-title fs-5 fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" 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.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
Edit User
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" id="editUserForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Display Name</label>
|
||||
<label class="form-label fw-medium">Display Name</label>
|
||||
<input class="form-control" type="text" name="display_name" id="editUserDisplayName" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-primary" type="submit">Save Changes</button>
|
||||
<button class="btn btn-primary" 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="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -254,22 +342,40 @@
|
||||
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">Delete User</h1>
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h1 class="modal-title fs-5 fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
||||
<path d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
|
||||
<path fill-rule="evenodd" d="M11 1.5v1h5v1h-1v9a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-9H0v-1h5v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118z"/>
|
||||
</svg>
|
||||
Delete User
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete user <strong id="deleteUserLabel"></strong>?</p>
|
||||
<div id="deleteSelfWarning" class="alert alert-danger d-none">
|
||||
<strong>Warning:</strong> You are deleting your own account. You will be logged out immediately and will lose access to this session.
|
||||
<div id="deleteSelfWarning" class="alert alert-danger d-flex align-items-start d-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.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.566z"/>
|
||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Warning:</strong> You are deleting your own account. You will be logged out immediately.
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||
<p class="text-danger small mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="post" id="deleteUserForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button class="btn btn-danger" type="submit">Delete User</button>
|
||||
<button class="btn btn-danger" 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="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,27 +386,54 @@
|
||||
<div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">Rotate Secret Key</h1>
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h1 class="modal-title fs-5 fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Rotate Secret Key
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="rotateSecretConfirm">
|
||||
<p>Are you sure you want to rotate the secret key for <strong id="rotateUserLabel"></strong>?</p>
|
||||
<div class="alert alert-warning mb-0">
|
||||
The old secret key will stop working immediately. Any applications using it must be updated.
|
||||
<p>Rotate the secret key for <strong id="rotateUserLabel"></strong>?</p>
|
||||
<div class="alert alert-warning d-flex align-items-start mb-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.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.566z"/>
|
||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
||||
</svg>
|
||||
<div>The old secret key will stop working immediately. Update any applications using it.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body d-none" id="rotateSecretResult">
|
||||
<p class="mb-2">Secret rotated successfully!</p>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control font-monospace" id="newSecretKey" readonly>
|
||||
<button class="btn btn-outline-primary" type="button" id="copyNewSecret">Copy</button>
|
||||
<div class="alert alert-success d-flex align-items-center mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2" 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>Secret rotated successfully!</div>
|
||||
</div>
|
||||
<p class="small text-muted mb-0">Copy this now. It will not be shown again.</p>
|
||||
<label class="form-label fw-medium">New Secret Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace bg-body-tertiary" id="newSecretKey" readonly>
|
||||
<button class="btn btn-outline-primary" type="button" id="copyNewSecret">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-text mb-0">Copy this now. It will not be shown again.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="rotateCancelBtn">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmRotateBtn">Rotate Key</button>
|
||||
<button type="button" class="btn btn-warning" id="confirmRotateBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Rotate Key
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary d-none" data-bs-dismiss="modal" id="rotateDoneBtn">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,6 +534,40 @@
|
||||
button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate));
|
||||
});
|
||||
|
||||
// Create User modal template buttons
|
||||
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', '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) {
|
||||
|
||||
@@ -1,29 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="row align-items-center mt-5 g-4">
|
||||
<div class="col-lg-6">
|
||||
<h1 class="display-6 mb-3">Welcome to <span class="text-primary">MyFSIO</span></h1>
|
||||
<p class="lead text-muted">A developer-friendly object storage solution for prototyping and local development.</p>
|
||||
<p class="text-muted mb-0">Need help getting started? Review the project README and docs for bootstrap credentials, IAM walkthroughs, and bucket policy samples.</p>
|
||||
<div class="row align-items-center justify-content-center min-vh-75 g-5">
|
||||
<div class="col-lg-5 d-none d-lg-block">
|
||||
<div class="text-center mb-4">
|
||||
<div class="position-relative d-inline-block mb-4">
|
||||
<div class="login-hero-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/>
|
||||
<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>
|
||||
</div>
|
||||
<h1 class="display-5 fw-bold mb-3">Welcome to <span class="text-gradient">MyFSIO</span></h1>
|
||||
<p class="lead text-muted mb-4">A developer-friendly object storage solution for prototyping and local development.</p>
|
||||
<div class="d-flex justify-content-center gap-4 text-muted">
|
||||
<div class="text-center">
|
||||
<div class="h4 fw-bold text-gradient mb-1">S3</div>
|
||||
<small>Compatible</small>
|
||||
</div>
|
||||
<div class="vr"></div>
|
||||
<div class="text-center">
|
||||
<div class="h4 fw-bold text-gradient mb-1">Fast</div>
|
||||
<small>Local Storage</small>
|
||||
</div>
|
||||
<div class="vr"></div>
|
||||
<div class="text-center">
|
||||
<div class="h4 fw-bold text-gradient mb-1">Secure</div>
|
||||
<small>IAM Support</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 ms-auto">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h4 mb-3">Sign in</h2>
|
||||
<div class="col-lg-5 col-md-8 col-sm-10">
|
||||
<div class="card shadow-lg login-card position-relative">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="text-center mb-4 d-lg-none">
|
||||
<img src="{{ url_for('static', filename='images/MyFISO.png') }}" alt="MyFSIO" width="48" height="48" class="mb-3 rounded-3">
|
||||
<h2 class="h4 fw-bold">MyFSIO</h2>
|
||||
</div>
|
||||
<h2 class="h4 mb-1 d-none d-lg-block">Sign in</h2>
|
||||
<p class="text-muted mb-4 d-none d-lg-block">Enter your credentials to continue</p>
|
||||
<form method="post" action="{{ url_for('ui.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access key</label>
|
||||
<input class="form-control" type="text" name="access_key" required autofocus />
|
||||
<label class="form-label fw-medium">Access key</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-key text-muted" 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>
|
||||
</span>
|
||||
<input class="form-control" type="text" name="access_key" required autofocus placeholder="Enter your access key" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Secret key</label>
|
||||
<input class="form-control" type="password" name="secret_key" required />
|
||||
<label class="form-label fw-medium">Secret key</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shield-lock text-muted" 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>
|
||||
</span>
|
||||
<input class="form-control" type="password" name="secret_key" required placeholder="Enter your secret key" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit">Continue</button>
|
||||
<button class="btn btn-primary btn-lg w-100 fw-medium" type="submit">
|
||||
Sign in
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-right ms-2" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">Need help? Check the <a href="#" class="text-decoration-none">documentation</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.min-vh-75 { min-height: 75vh; }
|
||||
.login-hero-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||
border-radius: 50%;
|
||||
color: #3b82f6;
|
||||
margin: 0 auto;
|
||||
}
|
||||
[data-theme='dark'] .login-hero-icon {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
|
||||
color: #60a5fa;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,65 +2,78 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0 fw-semibold">System Metrics</h1>
|
||||
<p class="text-muted mb-0">Real-time server performance and storage usage.</p>
|
||||
<h1 class="h3 mb-1 fw-bold">System Metrics</h1>
|
||||
<p class="text-muted mb-0">Real-time server performance and storage usage</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span class="d-flex align-items-center gap-2 text-muted small">
|
||||
<span class="live-indicator"></span>
|
||||
Live
|
||||
</span>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<div class="card shadow-sm h-100 border-0">
|
||||
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">CPU Usage</h6>
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">CPU Usage</h6>
|
||||
<div class="icon-box bg-primary-subtle text-primary rounded-circle p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-cpu" viewBox="0 0 16 16">
|
||||
<path d="M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5a.5.5 0 0 1 .5-.5zm6 4H5v8h6V4zM5 13h6v-1h-1v1H5v-1H4v1zm0-12v1h1V1H5zm0 1h1v1H5V2zm0 1h1v1H5V3zm-1 1H3v1h1V4zm-1 1H2v1h1V5zm-1 1H1v1h1V6zm1 2H2v1h1V8zm1 1H3v1h1V9zm1 1H4v1h1v-1zm2 0h1v1h-1v-1zm2 0h1v1h-1v-1zm2-1h1v-1h-1v1zm0-2h1V6h-1v1zm0-2h1V4h-1v1z"/>
|
||||
<path d="M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5a.5.5 0 0 1 .5-.5zM5 4H5v8h6V4H5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="display-6 fw-bold mb-2">{{ cpu_percent }}%</h2>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar {% if cpu_percent > 80 %}bg-danger{% elif cpu_percent > 50 %}bg-warning{% else %}bg-success{% endif %}" role="progressbar" style="width: {{ cpu_percent }}%" aria-valuenow="{{ cpu_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
<h2 class="display-6 fw-bold mb-2 stat-value">{{ cpu_percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
|
||||
<div class="progress" style="height: 8px; border-radius: 4px;">
|
||||
<div class="progress-bar {% if cpu_percent > 80 %}bg-danger{% elif cpu_percent > 50 %}bg-warning{% else %}bg-primary{% endif %}" role="progressbar" style="width: {{ cpu_percent }}%"></div>
|
||||
</div>
|
||||
<div class="mt-2 d-flex justify-content-between">
|
||||
<small class="text-muted">Current load</small>
|
||||
<small class="{% if cpu_percent > 80 %}text-danger{% elif cpu_percent > 50 %}text-warning{% else %}text-success{% endif %}">
|
||||
{% if cpu_percent > 80 %}High{% elif cpu_percent > 50 %}Medium{% else %}Normal{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<div class="card shadow-sm h-100 border-0">
|
||||
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">Memory</h6>
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">Memory</h6>
|
||||
<div class="icon-box bg-info-subtle text-info rounded-circle p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-memory" viewBox="0 0 16 16">
|
||||
<path d="M1 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4.586a1 1 0 0 0 .707-.293l.353-.353a.5.5 0 0 1 .708 0l.353.353a1 1 0 0 0 .707.293H15a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H1Zm.5 1h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm5 0h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm4.5.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4ZM2 10v2H1v-2h1Zm2 0v2H3v-2h1Zm2 0v2H5v-2h1Zm3 0v2H8v-2h1Zm2 0v2h-1v-2h1Zm2 0v2h-1v-2h1Z"/>
|
||||
<path d="M1 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4.586a1 1 0 0 0 .707-.293l.353-.353a.5.5 0 0 1 .708 0l.353.353a1 1 0 0 0 .707.293H15a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H1Zm.5 1h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm5 0h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm4.5.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="display-6 fw-bold mb-2">{{ memory.percent }}%</h2>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar bg-info" role="progressbar" style="width: {{ memory.percent }}%" aria-valuenow="{{ memory.percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
<h2 class="display-6 fw-bold mb-2 stat-value">{{ memory.percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
|
||||
<div class="progress" style="height: 8px; border-radius: 4px;">
|
||||
<div class="progress-bar bg-info" role="progressbar" style="width: {{ memory.percent }}%"></div>
|
||||
</div>
|
||||
<div class="mt-2 small text-muted">
|
||||
{{ memory.used }} used of {{ memory.total }}
|
||||
<div class="mt-2 d-flex justify-content-between">
|
||||
<small class="text-muted">{{ memory.used }} used</small>
|
||||
<small class="text-muted">{{ memory.total }} total</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<div class="card shadow-sm h-100 border-0">
|
||||
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">Server Disk</h6>
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">Disk Space</h6>
|
||||
<div class="icon-box bg-warning-subtle text-warning rounded-circle p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-hdd" viewBox="0 0 16 16">
|
||||
<path d="M4.5 11a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 10.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
@@ -68,35 +81,40 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="display-6 fw-bold mb-2">{{ disk.percent }}%</h2>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ disk.percent }}%" aria-valuenow="{{ disk.percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
<h2 class="display-6 fw-bold mb-2 stat-value">{{ disk.percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
|
||||
<div class="progress" style="height: 8px; border-radius: 4px;">
|
||||
<div class="progress-bar {% if disk.percent > 90 %}bg-danger{% elif disk.percent > 75 %}bg-warning{% else %}bg-warning{% endif %}" role="progressbar" style="width: {{ disk.percent }}%"></div>
|
||||
</div>
|
||||
<div class="mt-2 small text-muted">
|
||||
{{ disk.free }} free of {{ disk.total }}
|
||||
<div class="mt-2 d-flex justify-content-between">
|
||||
<small class="text-muted">{{ disk.free }} free</small>
|
||||
<small class="text-muted">{{ disk.total }} total</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<div class="card shadow-sm h-100 border-0">
|
||||
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">App Storage</h6>
|
||||
<h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">Storage</h6>
|
||||
<div class="icon-box bg-success-subtle text-success rounded-circle p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-database" viewBox="0 0 16 16">
|
||||
<path d="M12.096 6.223A4.92 4.92 0 0 0 13 5.698V7c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 7.654 4 7.289 4 7v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
|
||||
<path d="M12.096 9.223A4.92 4.92 0 0 0 13 8.698V10c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 10.654 4 10.289 4 10v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
|
||||
<path d="M12.096 12.223A4.92 4.92 0 0 0 13 11.698V13c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 13.654 4 13.289 4 13v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
|
||||
<path d="M12.096 3.223A4.92 4.92 0 0 0 13 2.698V4c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 4.654 4 4.289 4 4v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
|
||||
<path d="M12.096.223A4.92 4.92 0 0 0 13-.302V1c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 1.654 4 1.289 4 1v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
|
||||
<path d="M4.318 2.687C5.234 2.271 6.536 2 8 2s2.766.27 3.682.687C12.644 3.125 13 3.627 13 4c0 .374-.356.875-1.318 1.313C10.766 5.729 9.464 6 8 6s-2.766-.27-3.682-.687C3.356 4.875 3 4.373 3 4c0-.374.356-.875 1.318-1.313ZM13 5.698V7c0 .374-.356.875-1.318 1.313C10.766 8.729 9.464 9 8 9s-2.766-.27-3.682-.687C3.356 7.875 3 7.373 3 7V5.698c.271.202.58.378.904.525C4.978 6.711 6.427 7 8 7s3.022-.289 4.096-.777A4.92 4.92 0 0 0 13 5.698ZM14 4c0-1.007-.875-1.755-1.904-2.223C11.022 1.289 9.573 1 8 1s-3.022.289-4.096.777C2.875 2.245 2 2.993 2 4v9c0 1.007.875 1.755 1.904 2.223C4.978 15.71 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13V4Zm-1 4.698V10c0 .374-.356.875-1.318 1.313C10.766 11.729 9.464 12 8 12s-2.766-.27-3.682-.687C3.356 10.875 3 10.373 3 10V8.698c.271.202.58.378.904.525C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777A4.92 4.92 0 0 0 13 8.698Zm0 3V13c0 .374-.356.875-1.318 1.313C10.766 14.729 9.464 15 8 15s-2.766-.27-3.682-.687C3.356 13.875 3 13.373 3 13v-1.302c.271.202.58.378.904.525C4.978 12.71 6.427 13 8 13s3.022-.289 4.096-.777c.324-.147.633-.323.904-.525Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="display-6 fw-bold mb-2">{{ app.storage_used }}</h2>
|
||||
<div class="mt-2 small text-muted">
|
||||
{{ app.objects }} objects in {{ app.buckets }} buckets
|
||||
<h2 class="display-6 fw-bold mb-2 stat-value">{{ app.storage_used }}</h2>
|
||||
<div class="d-flex gap-3 mt-3">
|
||||
<div class="text-center flex-fill">
|
||||
<div class="h5 fw-bold mb-0">{{ app.buckets }}</div>
|
||||
<small class="text-muted">Buckets</small>
|
||||
</div>
|
||||
<div class="vr"></div>
|
||||
<div class="text-center flex-fill">
|
||||
<div class="h5 fw-bold mb-0">{{ app.objects }}</div>
|
||||
<small class="text-muted">Objects</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,47 +124,93 @@
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-transparent border-0 pt-4 px-4">
|
||||
<h5 class="card-title mb-0">Storage Distribution</h5>
|
||||
<div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0 fw-semibold">System Overview</h5>
|
||||
<span class="badge bg-primary-subtle text-primary">Live</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Value</th>
|
||||
<th>Status</th>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="text-muted small text-uppercase">
|
||||
<th class="fw-semibold border-0 pb-3">Resource</th>
|
||||
<th class="fw-semibold border-0 pb-3">Value</th>
|
||||
<th class="fw-semibold border-0 pb-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Total Server Capacity</td>
|
||||
<td class="fw-medium">{{ disk.total }}</td>
|
||||
<td><span class="badge bg-secondary">Hardware</span></td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-secondary-subtle rounded p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hdd-stack text-secondary" viewBox="0 0 16 16">
|
||||
<path d="M14 10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h12zM2 9a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1a2 2 0 0 0-2-2H2z"/>
|
||||
<path d="M5 11.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zM14 3a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h12zM2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2z"/>
|
||||
<path d="M5 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="fw-medium">Total Disk Capacity</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 fw-semibold">{{ disk.total }}</td>
|
||||
<td class="py-3"><span class="badge bg-secondary-subtle text-secondary">Hardware</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Available Space</td>
|
||||
<td class="fw-medium">{{ disk.free }}</td>
|
||||
<td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-success-subtle rounded p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-circle text-success" 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="M10.97 4.97a.235.235 0 0 0-.02.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-1.071-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="fw-medium">Available Space</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 fw-semibold">{{ disk.free }}</td>
|
||||
<td class="py-3">
|
||||
{% if disk.percent > 90 %}
|
||||
<span class="badge bg-danger">Critical</span>
|
||||
<span class="status-badge status-badge-danger badge bg-danger-subtle text-danger">
|
||||
<span class="status-badge-dot"></span>Critical
|
||||
</span>
|
||||
{% elif disk.percent > 75 %}
|
||||
<span class="badge bg-warning text-dark">Low</span>
|
||||
<span class="status-badge status-badge-warning badge bg-warning-subtle text-warning">
|
||||
<span class="status-badge-dot"></span>Low
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Good</span>
|
||||
<span class="status-badge status-badge-success badge bg-success-subtle text-success">
|
||||
<span class="status-badge-dot"></span>Good
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MyFSIO Data</td>
|
||||
<td class="fw-medium">{{ app.storage_used }}</td>
|
||||
<td><span class="badge bg-primary">Application</span></td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-primary-subtle rounded p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bucket text-primary" 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>
|
||||
</div>
|
||||
<span class="fw-medium">MyFSIO Data</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 fw-semibold">{{ app.storage_used }}</td>
|
||||
<td class="py-3"><span class="badge bg-primary-subtle text-primary">Application</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Object Count</td>
|
||||
<td class="fw-medium">{{ app.objects }}</td>
|
||||
<td><span class="badge bg-info">Count</span></td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-info-subtle rounded p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark text-info" viewBox="0 0 16 16">
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="fw-medium">Total Objects</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 fw-semibold">{{ app.objects }}</td>
|
||||
<td class="py-3"><span class="badge bg-info-subtle text-info">Count</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -156,16 +220,34 @@
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm border-0 bg-primary text-white h-100">
|
||||
<div class="card-body p-4 d-flex flex-column justify-content-center text-center">
|
||||
<div class="mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-cloud-check" viewBox="0 0 16 16">
|
||||
<div class="card shadow-sm border-0 h-100 overflow-hidden" style="background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);">
|
||||
<div class="card-body p-4 d-flex flex-column justify-content-center text-white position-relative">
|
||||
<div class="position-absolute top-0 end-0 opacity-25" style="transform: translate(20%, -20%);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" fill="currentColor" class="bi bi-cloud-check" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
<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.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
|
||||
<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>
|
||||
<h4 class="card-title">System Health</h4>
|
||||
<p class="card-text opacity-75">All systems are running smoothly. Storage capacity is within healthy limits.</p>
|
||||
<div class="mb-3">
|
||||
<span class="badge bg-white text-primary fw-semibold px-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-check-circle-fill 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>
|
||||
Healthy
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="card-title fw-bold mb-3">System Status</h4>
|
||||
<p class="card-text opacity-90 mb-4">All systems operational. Your storage infrastructure is running smoothly with no detected issues.</p>
|
||||
<div class="d-flex gap-4">
|
||||
<div>
|
||||
<div class="h3 fw-bold mb-0">99.9%</div>
|
||||
<small class="opacity-75">Uptime</small>
|
||||
</div>
|
||||
<div>
|
||||
<div class="h3 fw-bold mb-0">{{ app.buckets }}</div>
|
||||
<small class="opacity-75">Active Buckets</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,14 +24,6 @@ def test_boto3_basic_operations(live_server):
|
||||
),
|
||||
)
|
||||
|
||||
# No need to inject custom headers anymore, as we support SigV4
|
||||
# def _inject_headers(params, **_kwargs):
|
||||
# headers = params.setdefault("headers", {})
|
||||
# headers["X-Access-Key"] = "test"
|
||||
# headers["X-Secret-Key"] = "secret"
|
||||
|
||||
# s3.meta.events.register("before-call.s3", _inject_headers)
|
||||
|
||||
s3.create_bucket(Bucket=bucket_name)
|
||||
|
||||
try:
|
||||
|
||||
286
tests/test_new_api_endpoints.py
Normal file
286
tests/test_new_api_endpoints.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Tests for newly implemented S3 API endpoints."""
|
||||
import io
|
||||
import pytest
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
|
||||
# Helper to create file-like stream
|
||||
def _stream(data: bytes):
|
||||
return io.BytesIO(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(app):
|
||||
"""Get the storage instance from the app."""
|
||||
return app.extensions["object_storage"]
|
||||
|
||||
|
||||
class TestListObjectsV2:
|
||||
"""Tests for ListObjectsV2 endpoint."""
|
||||
|
||||
def test_list_objects_v2_basic(self, client, signer, storage):
|
||||
# Create bucket and objects
|
||||
storage.create_bucket("v2-test")
|
||||
storage.put_object("v2-test", "file1.txt", _stream(b"hello"))
|
||||
storage.put_object("v2-test", "file2.txt", _stream(b"world"))
|
||||
storage.put_object("v2-test", "folder/file3.txt", _stream(b"nested"))
|
||||
|
||||
# ListObjectsV2 request
|
||||
headers = signer("GET", "/v2-test?list-type=2")
|
||||
resp = client.get("/v2-test", query_string={"list-type": "2"}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
root = fromstring(resp.data)
|
||||
assert root.find("KeyCount").text == "3"
|
||||
assert root.find("IsTruncated").text == "false"
|
||||
|
||||
keys = [el.find("Key").text for el in root.findall("Contents")]
|
||||
assert "file1.txt" in keys
|
||||
assert "file2.txt" in keys
|
||||
assert "folder/file3.txt" in keys
|
||||
|
||||
def test_list_objects_v2_with_prefix_and_delimiter(self, client, signer, storage):
|
||||
storage.create_bucket("prefix-test")
|
||||
storage.put_object("prefix-test", "photos/2023/jan.jpg", _stream(b"jan"))
|
||||
storage.put_object("prefix-test", "photos/2023/feb.jpg", _stream(b"feb"))
|
||||
storage.put_object("prefix-test", "photos/2024/mar.jpg", _stream(b"mar"))
|
||||
storage.put_object("prefix-test", "docs/readme.md", _stream(b"readme"))
|
||||
|
||||
# List with prefix and delimiter
|
||||
headers = signer("GET", "/prefix-test?list-type=2&prefix=photos/&delimiter=/")
|
||||
resp = client.get(
|
||||
"/prefix-test",
|
||||
query_string={"list-type": "2", "prefix": "photos/", "delimiter": "/"},
|
||||
headers=headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
root = fromstring(resp.data)
|
||||
# Should show common prefixes for 2023/ and 2024/
|
||||
prefixes = [el.find("Prefix").text for el in root.findall("CommonPrefixes")]
|
||||
assert "photos/2023/" in prefixes
|
||||
assert "photos/2024/" in prefixes
|
||||
assert len(root.findall("Contents")) == 0 # No direct files under photos/
|
||||
|
||||
|
||||
class TestPutBucketVersioning:
|
||||
"""Tests for PutBucketVersioning endpoint."""
|
||||
|
||||
def test_put_versioning_enabled(self, client, signer, storage):
|
||||
storage.create_bucket("version-test")
|
||||
|
||||
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<VersioningConfiguration>
|
||||
<Status>Enabled</Status>
|
||||
</VersioningConfiguration>"""
|
||||
|
||||
headers = signer("PUT", "/version-test?versioning", body=payload)
|
||||
resp = client.put("/version-test", query_string={"versioning": ""}, data=payload, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify via GET
|
||||
headers = signer("GET", "/version-test?versioning")
|
||||
resp = client.get("/version-test", query_string={"versioning": ""}, headers=headers)
|
||||
root = fromstring(resp.data)
|
||||
assert root.find("Status").text == "Enabled"
|
||||
|
||||
def test_put_versioning_suspended(self, client, signer, storage):
|
||||
storage.create_bucket("suspend-test")
|
||||
storage.set_bucket_versioning("suspend-test", True)
|
||||
|
||||
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<VersioningConfiguration>
|
||||
<Status>Suspended</Status>
|
||||
</VersioningConfiguration>"""
|
||||
|
||||
headers = signer("PUT", "/suspend-test?versioning", body=payload)
|
||||
resp = client.put("/suspend-test", query_string={"versioning": ""}, data=payload, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
headers = signer("GET", "/suspend-test?versioning")
|
||||
resp = client.get("/suspend-test", query_string={"versioning": ""}, headers=headers)
|
||||
root = fromstring(resp.data)
|
||||
assert root.find("Status").text == "Suspended"
|
||||
|
||||
|
||||
class TestDeleteBucketTagging:
|
||||
"""Tests for DeleteBucketTagging endpoint."""
|
||||
|
||||
def test_delete_bucket_tags(self, client, signer, storage):
|
||||
storage.create_bucket("tag-delete-test")
|
||||
storage.set_bucket_tags("tag-delete-test", [{"Key": "env", "Value": "test"}])
|
||||
|
||||
# Delete tags
|
||||
headers = signer("DELETE", "/tag-delete-test?tagging")
|
||||
resp = client.delete("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify tags are gone
|
||||
headers = signer("GET", "/tag-delete-test?tagging")
|
||||
resp = client.get("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
|
||||
assert resp.status_code == 404 # NoSuchTagSet
|
||||
|
||||
|
||||
class TestDeleteBucketCors:
|
||||
"""Tests for DeleteBucketCors endpoint."""
|
||||
|
||||
def test_delete_bucket_cors(self, client, signer, storage):
|
||||
storage.create_bucket("cors-delete-test")
|
||||
storage.set_bucket_cors("cors-delete-test", [
|
||||
{"AllowedOrigins": ["*"], "AllowedMethods": ["GET"]}
|
||||
])
|
||||
|
||||
# Delete CORS
|
||||
headers = signer("DELETE", "/cors-delete-test?cors")
|
||||
resp = client.delete("/cors-delete-test", query_string={"cors": ""}, headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify CORS is gone
|
||||
headers = signer("GET", "/cors-delete-test?cors")
|
||||
resp = client.get("/cors-delete-test", query_string={"cors": ""}, headers=headers)
|
||||
assert resp.status_code == 404 # NoSuchCORSConfiguration
|
||||
|
||||
|
||||
class TestGetBucketLocation:
|
||||
"""Tests for GetBucketLocation endpoint."""
|
||||
|
||||
def test_get_bucket_location(self, client, signer, storage):
|
||||
storage.create_bucket("location-test")
|
||||
|
||||
headers = signer("GET", "/location-test?location")
|
||||
resp = client.get("/location-test", query_string={"location": ""}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
root = fromstring(resp.data)
|
||||
assert root.tag == "LocationConstraint"
|
||||
|
||||
|
||||
class TestBucketAcl:
|
||||
"""Tests for Bucket ACL operations."""
|
||||
|
||||
def test_get_bucket_acl(self, client, signer, storage):
|
||||
storage.create_bucket("acl-test")
|
||||
|
||||
headers = signer("GET", "/acl-test?acl")
|
||||
resp = client.get("/acl-test", query_string={"acl": ""}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
root = fromstring(resp.data)
|
||||
assert root.tag == "AccessControlPolicy"
|
||||
assert root.find("Owner/ID") is not None
|
||||
assert root.find(".//Permission").text == "FULL_CONTROL"
|
||||
|
||||
def test_put_bucket_acl(self, client, signer, storage):
|
||||
storage.create_bucket("acl-put-test")
|
||||
|
||||
# PUT with canned ACL header
|
||||
headers = signer("PUT", "/acl-put-test?acl")
|
||||
headers["x-amz-acl"] = "public-read"
|
||||
resp = client.put("/acl-put-test", query_string={"acl": ""}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestCopyObject:
|
||||
"""Tests for CopyObject operation."""
|
||||
|
||||
def test_copy_object_basic(self, client, signer, storage):
|
||||
storage.create_bucket("copy-src")
|
||||
storage.create_bucket("copy-dst")
|
||||
storage.put_object("copy-src", "original.txt", _stream(b"original content"))
|
||||
|
||||
# Copy object
|
||||
headers = signer("PUT", "/copy-dst/copied.txt")
|
||||
headers["x-amz-copy-source"] = "/copy-src/original.txt"
|
||||
resp = client.put("/copy-dst/copied.txt", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
root = fromstring(resp.data)
|
||||
assert root.tag == "CopyObjectResult"
|
||||
assert root.find("ETag") is not None
|
||||
assert root.find("LastModified") is not None
|
||||
|
||||
# Verify copy exists
|
||||
path = storage.get_object_path("copy-dst", "copied.txt")
|
||||
assert path.read_bytes() == b"original content"
|
||||
|
||||
def test_copy_object_with_metadata_replace(self, client, signer, storage):
|
||||
storage.create_bucket("meta-src")
|
||||
storage.create_bucket("meta-dst")
|
||||
storage.put_object("meta-src", "source.txt", _stream(b"data"), metadata={"old": "value"})
|
||||
|
||||
# Copy with REPLACE directive
|
||||
headers = signer("PUT", "/meta-dst/target.txt")
|
||||
headers["x-amz-copy-source"] = "/meta-src/source.txt"
|
||||
headers["x-amz-metadata-directive"] = "REPLACE"
|
||||
headers["x-amz-meta-new"] = "metadata"
|
||||
resp = client.put("/meta-dst/target.txt", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify new metadata (note: header keys are Title-Cased)
|
||||
meta = storage.get_object_metadata("meta-dst", "target.txt")
|
||||
assert "New" in meta or "new" in meta
|
||||
assert "old" not in meta and "Old" not in meta
|
||||
|
||||
|
||||
class TestObjectTagging:
|
||||
"""Tests for Object tagging operations."""
|
||||
|
||||
def test_put_get_delete_object_tags(self, client, signer, storage):
|
||||
storage.create_bucket("obj-tag-test")
|
||||
storage.put_object("obj-tag-test", "tagged.txt", _stream(b"content"))
|
||||
|
||||
# PUT tags
|
||||
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Tagging>
|
||||
<TagSet>
|
||||
<Tag><Key>project</Key><Value>demo</Value></Tag>
|
||||
<Tag><Key>env</Key><Value>test</Value></Tag>
|
||||
</TagSet>
|
||||
</Tagging>"""
|
||||
|
||||
headers = signer("PUT", "/obj-tag-test/tagged.txt?tagging", body=payload)
|
||||
resp = client.put(
|
||||
"/obj-tag-test/tagged.txt",
|
||||
query_string={"tagging": ""},
|
||||
data=payload,
|
||||
headers=headers
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# GET tags
|
||||
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
|
||||
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
root = fromstring(resp.data)
|
||||
tags = {el.find("Key").text: el.find("Value").text for el in root.findall(".//Tag")}
|
||||
assert tags["project"] == "demo"
|
||||
assert tags["env"] == "test"
|
||||
|
||||
# DELETE tags
|
||||
headers = signer("DELETE", "/obj-tag-test/tagged.txt?tagging")
|
||||
resp = client.delete("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify empty
|
||||
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
|
||||
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
||||
root = fromstring(resp.data)
|
||||
assert len(root.findall(".//Tag")) == 0
|
||||
|
||||
def test_object_tags_limit(self, client, signer, storage):
|
||||
storage.create_bucket("tag-limit")
|
||||
storage.put_object("tag-limit", "file.txt", _stream(b"x"))
|
||||
|
||||
# Try to set 11 tags (limit is 10)
|
||||
tags = "".join(f"<Tag><Key>key{i}</Key><Value>val{i}</Value></Tag>" for i in range(11))
|
||||
payload = f"<Tagging><TagSet>{tags}</TagSet></Tagging>".encode()
|
||||
|
||||
headers = signer("PUT", "/tag-limit/file.txt?tagging", body=payload)
|
||||
resp = client.put(
|
||||
"/tag-limit/file.txt",
|
||||
query_string={"tagging": ""},
|
||||
data=payload,
|
||||
headers=headers
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -99,11 +99,11 @@ def test_delete_object_retries_when_locked(tmp_path, monkeypatch):
|
||||
original_unlink = Path.unlink
|
||||
attempts = {"count": 0}
|
||||
|
||||
def flaky_unlink(self):
|
||||
def flaky_unlink(self, missing_ok=False):
|
||||
if self == target_path and attempts["count"] < 1:
|
||||
attempts["count"] += 1
|
||||
raise PermissionError("locked")
|
||||
return original_unlink(self)
|
||||
return original_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", flaky_unlink)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user