diff --git a/app/config.py b/app/config.py index 82308b8..f585b51 100644 --- a/app/config.py +++ b/app/config.py @@ -76,6 +76,9 @@ class AppConfig: display_timezone: str lifecycle_enabled: bool lifecycle_interval_seconds: int + metrics_history_enabled: bool + metrics_history_retention_hours: int + metrics_history_interval_minutes: int @classmethod def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig": @@ -172,6 +175,9 @@ class AppConfig: kms_keys_path = Path(_get("KMS_KEYS_PATH", encryption_keys_dir / "kms_keys.json")).resolve() default_encryption_algorithm = str(_get("DEFAULT_ENCRYPTION_ALGORITHM", "AES256")) display_timezone = str(_get("DISPLAY_TIMEZONE", "UTC")) + metrics_history_enabled = str(_get("METRICS_HISTORY_ENABLED", "0")).lower() in {"1", "true", "yes", "on"} + metrics_history_retention_hours = int(_get("METRICS_HISTORY_RETENTION_HOURS", 24)) + metrics_history_interval_minutes = int(_get("METRICS_HISTORY_INTERVAL_MINUTES", 5)) return cls(storage_root=storage_root, max_upload_size=max_upload_size, @@ -210,7 +216,10 @@ class AppConfig: default_encryption_algorithm=default_encryption_algorithm, display_timezone=display_timezone, lifecycle_enabled=lifecycle_enabled, - lifecycle_interval_seconds=lifecycle_interval_seconds) + lifecycle_interval_seconds=lifecycle_interval_seconds, + metrics_history_enabled=metrics_history_enabled, + metrics_history_retention_hours=metrics_history_retention_hours, + metrics_history_interval_minutes=metrics_history_interval_minutes) def validate_and_report(self) -> list[str]: """Validate configuration and return a list of warnings/issues. @@ -339,4 +348,7 @@ class AppConfig: "DISPLAY_TIMEZONE": self.display_timezone, "LIFECYCLE_ENABLED": self.lifecycle_enabled, "LIFECYCLE_INTERVAL_SECONDS": self.lifecycle_interval_seconds, + "METRICS_HISTORY_ENABLED": self.metrics_history_enabled, + "METRICS_HISTORY_RETENTION_HOURS": self.metrics_history_retention_hours, + "METRICS_HISTORY_INTERVAL_MINUTES": self.metrics_history_interval_minutes, } diff --git a/app/ui.py b/app/ui.py index 28f87f5..982a730 100644 --- a/app/ui.py +++ b/app/ui.py @@ -6,6 +6,7 @@ import uuid import psutil import shutil from datetime import datetime, timezone as dt_timezone +from pathlib import Path from typing import Any from urllib.parse import quote, urlparse from zoneinfo import ZoneInfo @@ -152,6 +153,69 @@ def _format_bytes(num: int) -> str: return f"{value:.1f} PB" +_metrics_last_save_time: float = 0.0 + + +def _get_metrics_history_path() -> Path: + storage_root = Path(current_app.config["STORAGE_ROOT"]) + return storage_root / ".myfsio.sys" / "config" / "metrics_history.json" + + +def _load_metrics_history() -> dict: + path = _get_metrics_history_path() + if not path.exists(): + return {"history": []} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {"history": []} + + +def _save_metrics_snapshot(cpu_percent: float, memory_percent: float, disk_percent: float, storage_bytes: int) -> None: + global _metrics_last_save_time + + if not current_app.config.get("METRICS_HISTORY_ENABLED", False): + return + + import time + from datetime import datetime, timezone + + interval_minutes = current_app.config.get("METRICS_HISTORY_INTERVAL_MINUTES", 5) + now_ts = time.time() + if now_ts - _metrics_last_save_time < interval_minutes * 60: + return + + path = _get_metrics_history_path() + path.parent.mkdir(parents=True, exist_ok=True) + + data = _load_metrics_history() + history = data.get("history", []) + retention_hours = current_app.config.get("METRICS_HISTORY_RETENTION_HOURS", 24) + + now = datetime.now(timezone.utc) + snapshot = { + "timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "cpu_percent": round(cpu_percent, 2), + "memory_percent": round(memory_percent, 2), + "disk_percent": round(disk_percent, 2), + "storage_bytes": storage_bytes, + } + history.append(snapshot) + + cutoff = now.timestamp() - (retention_hours * 3600) + history = [ + h for h in history + if datetime.fromisoformat(h["timestamp"].replace("Z", "+00:00")).timestamp() > cutoff + ] + + data["history"] = history + try: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + _metrics_last_save_time = now_ts + except OSError: + pass + + def _friendly_error_message(exc: Exception) -> str: message = str(exc) or "An unexpected error occurred" if isinstance(exc, IamError): @@ -2101,18 +2165,18 @@ def metrics_dashboard(): return render_template( "metrics.html", principal=principal, - cpu_percent=cpu_percent, + cpu_percent=round(cpu_percent, 2), memory={ "total": _format_bytes(memory.total), "available": _format_bytes(memory.available), "used": _format_bytes(memory.used), - "percent": memory.percent, + "percent": round(memory.percent, 2), }, disk={ "total": _format_bytes(disk.total), "free": _format_bytes(disk.free), "used": _format_bytes(disk.used), - "percent": disk.percent, + "percent": round(disk.percent, 2), }, app={ "buckets": total_buckets, @@ -2122,7 +2186,8 @@ def metrics_dashboard(): "storage_raw": total_bytes_used, "version": APP_VERSION, "uptime_days": uptime_days, - } + }, + metrics_history_enabled=current_app.config.get("METRICS_HISTORY_ENABLED", False), ) @@ -2162,19 +2227,21 @@ def metrics_api(): uptime_seconds = time.time() - boot_time uptime_days = int(uptime_seconds / 86400) + _save_metrics_snapshot(cpu_percent, memory.percent, disk.percent, total_bytes_used) + return jsonify({ - "cpu_percent": cpu_percent, + "cpu_percent": round(cpu_percent, 2), "memory": { "total": _format_bytes(memory.total), "available": _format_bytes(memory.available), "used": _format_bytes(memory.used), - "percent": memory.percent, + "percent": round(memory.percent, 2), }, "disk": { "total": _format_bytes(disk.total), "free": _format_bytes(disk.free), "used": _format_bytes(disk.used), - "percent": disk.percent, + "percent": round(disk.percent, 2), }, "app": { "buckets": total_buckets, @@ -2187,6 +2254,73 @@ def metrics_api(): }) +@ui_bp.route("/metrics/history") +def metrics_history(): + principal = _current_principal() + + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + return jsonify({"error": "Access denied"}), 403 + + if not current_app.config.get("METRICS_HISTORY_ENABLED", False): + return jsonify({"enabled": False, "history": []}) + + hours = request.args.get("hours", type=int) + if hours is None: + hours = current_app.config.get("METRICS_HISTORY_RETENTION_HOURS", 24) + + data = _load_metrics_history() + history = data.get("history", []) + + if hours: + from datetime import datetime, timezone + cutoff = datetime.now(timezone.utc).timestamp() - (hours * 3600) + history = [ + h for h in history + if datetime.fromisoformat(h["timestamp"].replace("Z", "+00:00")).timestamp() > cutoff + ] + + return jsonify({ + "enabled": True, + "retention_hours": current_app.config.get("METRICS_HISTORY_RETENTION_HOURS", 24), + "interval_minutes": current_app.config.get("METRICS_HISTORY_INTERVAL_MINUTES", 5), + "history": history, + }) + + +@ui_bp.route("/metrics/settings", methods=["GET", "PUT"]) +def metrics_settings(): + principal = _current_principal() + + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + return jsonify({"error": "Access denied"}), 403 + + if request.method == "GET": + return jsonify({ + "enabled": current_app.config.get("METRICS_HISTORY_ENABLED", False), + "retention_hours": current_app.config.get("METRICS_HISTORY_RETENTION_HOURS", 24), + "interval_minutes": current_app.config.get("METRICS_HISTORY_INTERVAL_MINUTES", 5), + }) + + data = request.get_json() or {} + + if "enabled" in data: + current_app.config["METRICS_HISTORY_ENABLED"] = bool(data["enabled"]) + if "retention_hours" in data: + current_app.config["METRICS_HISTORY_RETENTION_HOURS"] = max(1, int(data["retention_hours"])) + if "interval_minutes" in data: + current_app.config["METRICS_HISTORY_INTERVAL_MINUTES"] = max(1, int(data["interval_minutes"])) + + return jsonify({ + "enabled": current_app.config.get("METRICS_HISTORY_ENABLED", False), + "retention_hours": current_app.config.get("METRICS_HISTORY_RETENTION_HOURS", 24), + "interval_minutes": current_app.config.get("METRICS_HISTORY_INTERVAL_MINUTES", 5), + }) + + @ui_bp.route("/buckets//lifecycle", methods=["GET", "POST", "DELETE"]) def bucket_lifecycle(bucket_name: str): principal = _current_principal() diff --git a/templates/docs.html b/templates/docs.html index 43a47f5..56e5553 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -39,6 +39,7 @@
  • Bucket Quotas
  • Encryption
  • Lifecycle Rules
  • +
  • Metrics History
  • Troubleshooting
  • @@ -181,6 +182,24 @@ python run.py --mode ui true Enable file logging. + + Metrics History Settings + + + METRICS_HISTORY_ENABLED + false + Enable metrics history recording and charts (opt-in). + + + METRICS_HISTORY_RETENTION_HOURS + 24 + How long to retain metrics history data. + + + METRICS_HISTORY_INTERVAL_MINUTES + 5 + Interval between history snapshots. + @@ -976,10 +995,94 @@ curl "{{ api_base }}/<bucket>?lifecycle" \ -
    +
    13 +

    Metrics History

    +
    +

    Track CPU, memory, and disk usage over time with optional metrics history. Disabled by default to minimize overhead.

    + +

    Enabling Metrics History

    +

    Set the environment variable to opt-in:

    +
    # PowerShell
    +$env:METRICS_HISTORY_ENABLED = "true"
    +python run.py
    +
    +# Bash
    +export METRICS_HISTORY_ENABLED=true
    +python run.py
    + +

    Configuration Options

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    METRICS_HISTORY_ENABLEDfalseEnable/disable metrics history recording
    METRICS_HISTORY_RETENTION_HOURS24How long to keep history data (hours)
    METRICS_HISTORY_INTERVAL_MINUTES5Interval between snapshots (minutes)
    +
    + +

    API Endpoints

    +
    # Get metrics history (last 24 hours by default)
    +curl "{{ api_base | replace('/api', '/ui') }}/metrics/history" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
    +
    +# Get history for specific time range
    +curl "{{ api_base | replace('/api', '/ui') }}/metrics/history?hours=6" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
    +
    +# Get current settings
    +curl "{{ api_base | replace('/api', '/ui') }}/metrics/settings" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
    +
    +# Update settings at runtime
    +curl -X PUT "{{ api_base | replace('/api', '/ui') }}/metrics/settings" \
    +  -H "Content-Type: application/json" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
    +  -d '{"enabled": true, "retention_hours": 48, "interval_minutes": 10}'
    + +

    Storage Location

    +

    History data is stored at:

    + data/.myfsio.sys/config/metrics_history.json + +
    +
    + + + + +
    + UI Charts: When enabled, the Metrics dashboard displays line charts showing CPU, memory, and disk usage trends with a time range selector (1h, 6h, 24h, 7d). +
    +
    +
    +
    +
    +
    +
    +
    + 14

    Troubleshooting & tips

    @@ -1045,6 +1148,7 @@ curl "{{ api_base }}/<bucket>?lifecycle" \
  • Bucket Quotas
  • Encryption
  • Lifecycle Rules
  • +
  • Metrics History
  • Troubleshooting
  • diff --git a/templates/metrics.html b/templates/metrics.html index cab1fe6..55a3a28 100644 --- a/templates/metrics.html +++ b/templates/metrics.html @@ -267,9 +267,49 @@
    + +{% if metrics_history_enabled %} +
    +
    +
    +
    +
    Metrics History
    +
    + +
    +
    +
    +
    +
    +
    CPU Usage
    + +
    +
    +
    Memory Usage
    + +
    +
    +
    Disk Usage
    + +
    +
    +

    Loading history data...

    +
    +
    +
    +
    +{% endif %} {% endblock %} {% block extra_scripts %} +{% if metrics_history_enabled %} + +{% endif %} {% endblock %}