diff --git a/api/storage_s3.py b/api/storage_s3.py
index 4916161..f789998 100644
--- a/api/storage_s3.py
+++ b/api/storage_s3.py
@@ -3,6 +3,9 @@
from __future__ import annotations
import os
+import time
+from collections import deque
+from datetime import datetime, timezone
from typing import Any
import boto3
@@ -18,6 +21,47 @@ S3_REGION = os.getenv("S3_REGION", "us-east-1")
router = APIRouter(prefix="/api/storage/s3", tags=["storage"])
+# Bounded scan so the dashboard stays responsive on big buckets.
+SCAN_MAX_OBJECTS = int(os.getenv("S3_SCAN_MAX_OBJECTS", "50000"))
+SCAN_DEADLINE_S = float(os.getenv("S3_SCAN_DEADLINE_S", "12"))
+
+# In-process activity log — every S3 op routed through this API is recorded so
+# the dashboard can show live "what is happening on the storage" timelines.
+_activity: deque[tuple[float, str]] = deque(maxlen=20000)
+_analytics_cache: dict[str, Any] = {"ts": 0.0, "data": None}
+_ANALYTICS_TTL = 45.0
+
+
+def _track(op: str) -> None:
+ try:
+ _activity.append((time.time(), op))
+ except Exception:
+ pass
+
+
+def _ext(key: str) -> str:
+ base = key.rsplit("/", 1)[-1]
+ if "." in base:
+ e = base.rsplit(".", 1)[-1].lower()
+ if 1 <= len(e) <= 8 and e.isalnum():
+ return e
+ return "(none)"
+
+
+def _size_class(n: int) -> str:
+ kb, mb, gb = 1024, 1024 ** 2, 1024 ** 3
+ if n < kb:
+ return "<1 KB"
+ if n < mb:
+ return "1 KB–1 MB"
+ if n < 10 * mb:
+ return "1–10 MB"
+ if n < 100 * mb:
+ return "10–100 MB"
+ if n < gb:
+ return "100 MB–1 GB"
+ return ">1 GB"
+
def _client():
return boto3.client(
@@ -41,6 +85,7 @@ def _human_size(n: int) -> str:
@router.get("/health")
async def s3_health():
try:
+ _track("health")
s3 = _client()
buckets = s3.list_buckets()
names = [b["Name"] for b in buckets.get("Buckets", [])]
@@ -57,6 +102,7 @@ async def s3_health():
@router.get("/buckets")
async def list_buckets():
try:
+ _track("list_buckets")
s3 = _client()
resp = s3.list_buckets()
items = []
@@ -84,6 +130,7 @@ async def list_objects(
max_keys: int = Query(200, le=500),
):
try:
+ _track("list_objects")
s3 = _client()
resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, Delimiter="/", MaxKeys=max_keys)
folders = [
@@ -119,6 +166,7 @@ async def list_objects(
@router.get("/buckets/{bucket}/download")
async def download_object(bucket: str, key: str = Query(...)):
try:
+ _track("download")
s3 = _client()
obj = s3.get_object(Bucket=bucket, Key=key)
body = obj["Body"]
@@ -136,3 +184,176 @@ async def download_object(bucket: str, key: str = Query(...)):
)
except ClientError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=404)
+
+
+
+def _scan_analytics() -> dict[str, Any]:
+ s3 = _client()
+ deadline = time.time() + SCAN_DEADLINE_S
+ resp = s3.list_buckets()
+ bucket_objs = resp.get("Buckets", [])
+ created_map = {
+ b["Name"]: (b.get("CreationDate").isoformat() if b.get("CreationDate") else None)
+ for b in bucket_objs
+ }
+ names = [b["Name"] for b in bucket_objs]
+
+ total_bytes = 0
+ total_objects = 0
+ by_bucket: dict[str, list[int]] = {}
+ by_ext: dict[str, list[int]] = {}
+ by_size: dict[str, int] = {}
+ by_day: dict[str, list[int]] = {}
+ by_prefix: dict[str, list[int]] = {}
+ largest: list[tuple[int, str, str, str | None]] = []
+ recent: list[tuple[str, str, str, int]] = []
+ truncated = False
+ scanned = 0
+
+ for name in names:
+ bc = bb = 0
+ token = None
+ while True:
+ if time.time() > deadline or scanned >= SCAN_MAX_OBJECTS:
+ truncated = True
+ break
+ kw: dict[str, Any] = {"Bucket": name, "MaxKeys": 1000}
+ if token:
+ kw["ContinuationToken"] = token
+ try:
+ r = s3.list_objects_v2(**kw)
+ except Exception:
+ break
+ for o in r.get("Contents", []):
+ sz = int(o.get("Size", 0) or 0)
+ key = o["Key"]
+ lm = o.get("LastModified")
+ bc += 1
+ bb += sz
+ scanned += 1
+ total_objects += 1
+ total_bytes += sz
+ e = by_ext.setdefault(_ext(key), [0, 0])
+ e[0] += 1
+ e[1] += sz
+ sc = _size_class(sz)
+ by_size[sc] = by_size.get(sc, 0) + 1
+ if lm:
+ d = lm.astimezone(timezone.utc).strftime("%Y-%m-%d")
+ dd = by_day.setdefault(d, [0, 0])
+ dd[0] += 1
+ dd[1] += sz
+ recent.append((lm.isoformat(), name, key, sz))
+ top = key.split("/", 1)[0] if "/" in key else "(root)"
+ pp = by_prefix.setdefault(f"{name}/{top}", [0, 0])
+ pp[0] += 1
+ pp[1] += sz
+ largest.append((sz, name, key, lm.isoformat() if lm else None))
+ if scanned >= SCAN_MAX_OBJECTS:
+ truncated = True
+ break
+ if r.get("IsTruncated") and not (time.time() > deadline or scanned >= SCAN_MAX_OBJECTS):
+ token = r.get("NextContinuationToken")
+ if not token:
+ break
+ else:
+ break
+ by_bucket[name] = [bc, bb]
+
+ # cumulative growth timeline
+ growth = []
+ cum_b = cum_o = 0
+ for d in sorted(by_day):
+ c, b = by_day[d]
+ cum_o += c
+ cum_b += b
+ growth.append({"date": d, "objects": c, "bytes": b, "cum_objects": cum_o, "cum_bytes": cum_b})
+
+ buckets_out = sorted(
+ ([{"name": n, "objects": v[0], "bytes": v[1], "size_human": _human_size(v[1]),
+ "pct": round(100 * v[1] / total_bytes, 1) if total_bytes else 0,
+ "created": created_map.get(n)} for n, v in by_bucket.items()]),
+ key=lambda x: x["bytes"], reverse=True)
+
+ types_out = sorted(
+ ([{"ext": k, "objects": v[0], "bytes": v[1], "size_human": _human_size(v[1])}
+ for k, v in by_ext.items()]), key=lambda x: x["bytes"], reverse=True)[:12]
+
+ size_order = ["<1 KB", "1 KB–1 MB", "1–10 MB", "10–100 MB", "100 MB–1 GB", ">1 GB"]
+ size_out = [{"label": k, "count": by_size.get(k, 0)} for k in size_order]
+
+ prefixes_out = sorted(
+ ([{"prefix": k, "objects": v[0], "bytes": v[1], "size_human": _human_size(v[1])}
+ for k, v in by_prefix.items()]), key=lambda x: x["bytes"], reverse=True)[:10]
+
+ largest.sort(key=lambda x: x[0], reverse=True)
+ largest_out = [{"bucket": b, "key": k, "bytes": s, "size_human": _human_size(s), "modified": m}
+ for s, b, k, m in largest[:10]]
+
+ recent.sort(key=lambda x: x[0], reverse=True)
+ recent_out = [{"modified": m, "bucket": b, "key": k, "bytes": s, "size_human": _human_size(s)}
+ for m, b, k, s in recent[:15]]
+
+ return {
+ "summary": {
+ "buckets": len(names),
+ "objects": total_objects,
+ "bytes": total_bytes,
+ "size_human": _human_size(total_bytes),
+ "avg_object_bytes": int(total_bytes / total_objects) if total_objects else 0,
+ "avg_object_human": _human_size(int(total_bytes / total_objects) if total_objects else 0),
+ "largest_human": largest_out[0]["size_human"] if largest_out else "0 B",
+ "newest": recent_out[0]["modified"] if recent_out else None,
+ "oldest": growth[0]["date"] if growth else None,
+ "truncated": truncated,
+ "scanned": scanned,
+ },
+ "buckets": buckets_out,
+ "types": types_out,
+ "size_histogram": size_out,
+ "growth": growth,
+ "top_prefixes": prefixes_out,
+ "largest_objects": largest_out,
+ "recent": recent_out,
+ }
+
+
+def _activity_view() -> dict[str, Any]:
+ now = time.time()
+ cutoff = now - 3600
+ mins = [0] * 60
+ by_op: dict[str, int] = {}
+ total = 0
+ for ts, op in list(_activity):
+ if ts < cutoff:
+ continue
+ total += 1
+ by_op[op] = by_op.get(op, 0) + 1
+ idx = int((now - ts) // 60)
+ if 0 <= idx < 60:
+ mins[59 - idx] += 1
+ last_ts = _activity[-1][0] if _activity else None
+ return {
+ "per_minute": mins,
+ "by_op": [{"op": k, "count": v} for k, v in sorted(by_op.items(), key=lambda x: -x[1])],
+ "total_last_hour": total,
+ "last_activity": datetime.fromtimestamp(last_ts, tz=timezone.utc).isoformat() if last_ts else None,
+ }
+
+
+@router.get("/analytics")
+async def analytics(refresh: bool = Query(False)):
+ """Aggregated storage analytics for the dashboard (cached ~45s)."""
+ _track("analytics")
+ now = time.time()
+ if not refresh and _analytics_cache["data"] is not None and now - _analytics_cache["ts"] < _ANALYTICS_TTL:
+ data = _analytics_cache["data"]
+ else:
+ try:
+ data = _scan_analytics()
+ _analytics_cache["data"] = data
+ _analytics_cache["ts"] = now
+ except Exception as exc:
+ return JSONResponse({"ok": False, "error": str(exc), "endpoint": S3_ENDPOINT}, status_code=502)
+ return {"ok": True, "endpoint": S3_ENDPOINT, "generated_at": datetime.now(timezone.utc).isoformat(),
+ "activity": _activity_view(), **data}
diff --git a/ui/src/components/features/StorageView.tsx b/ui/src/components/features/StorageView.tsx
index a7f0279..a8dd9a0 100644
--- a/ui/src/components/features/StorageView.tsx
+++ b/ui/src/components/features/StorageView.tsx
@@ -1,13 +1,355 @@
-import { useCallback, useEffect, useState } from 'react'
-import { ChevronRight, Database, Download, ExternalLink, Folder, HardDrive, Loader2, RefreshCw } from 'lucide-react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import {
+ Activity, BarChart3, Boxes, ChevronRight, Clock, Database, Download, ExternalLink,
+ Files, Folder, Gauge, HardDrive, LayoutGrid, Loader2, RefreshCw, TrendingUp,
+} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Bucket = { name: string; created?: string; has_objects?: boolean }
type S3Item = { type: string; name?: string; prefix?: string; key?: string; size_human?: string; modified?: string }
+type GrowthPt = { date: string; objects: number; bytes: number; cum_objects: number; cum_bytes: number }
+type Analytics = {
+ ok: boolean
+ endpoint?: string
+ generated_at?: string
+ summary?: {
+ buckets: number; objects: number; bytes: number; size_human: string
+ avg_object_human: string; largest_human: string; newest?: string | null
+ oldest?: string | null; truncated?: boolean; scanned?: number
+ }
+ buckets?: { name: string; objects: number; bytes: number; size_human: string; pct: number; created?: string | null }[]
+ types?: { ext: string; objects: number; bytes: number; size_human: string }[]
+ size_histogram?: { label: string; count: number }[]
+ growth?: GrowthPt[]
+ top_prefixes?: { prefix: string; objects: number; bytes: number; size_human: string }[]
+ largest_objects?: { bucket: string; key: string; bytes: number; size_human: string; modified?: string | null }[]
+ recent?: { modified?: string | null; bucket: string; key: string; bytes: number; size_human: string }[]
+ activity?: { per_minute: number[]; by_op: { op: string; count: number }[]; total_last_hour: number; last_activity?: string | null }
+}
+
+const PALETTE = ['#38bdf8', '#34d399', '#a78bfa', '#fbbf24', '#fb7185', '#22d3ee', '#f472b6', '#818cf8', '#4ade80', '#fb923c']
+
+function fmtBytes(n: number): string {
+ const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
+ let i = 0
+ let x = n
+ while (x >= 1024 && i < u.length - 1) { x /= 1024; i++ }
+ return `${i === 0 ? x : x.toFixed(1)} ${u[i]}`
+}
+const fmtNum = (n: number) => n.toLocaleString()
+
+/* ── Tiny SVG charts (no deps) ───────────────────────────────────────────── */
+
+function GrowthChart({ data }: { data: GrowthPt[] }) {
+ const W = 720, H = 200, padB = 22, padL = 4
+ if (!data.length) return
+ Dell ECS · {an?.endpoint || '10.0.20.111:9020'} · {s ? `${fmtNum(s.objects)} objects · ${s.size_human}` : 'live analytics'} + {s?.truncated && (sampled {fmtNum(s.scanned || 0)})} +
+{anErr}
} + {!an && anLoading &&- Dell ECS · {health?.endpoint || '10.0.20.111:9020'} · live bucket browser -
+