Skip to content

Commit 1a48631

Browse files
feat: add admin dashboard for key & upload status monitoring
Lightweight web UI served at /admin (disabled by default, enable with S3PROXY_ADMIN_UI=true) showing KEK fingerprint, active multipart uploads, memory/concurrency health, and request statistics. Server-rendered HTML with auto-refresh via fetch() every 10s, protected by HTTP Basic Auth. Closes #19
1 parent ffdef7d commit 1a48631

11 files changed

Lines changed: 750 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ target-version = "py314"
7070
[tool.ruff.lint]
7171
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
7272

73+
[tool.ruff.lint.per-file-ignores]
74+
"s3proxy/admin/templates.py" = ["E501"]
75+
7376
[tool.pytest.ini_options]
7477
asyncio_mode = "auto"
7578
testpaths = ["tests"]

s3proxy/admin/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Admin dashboard for S3Proxy."""
2+
3+
from .router import create_admin_router
4+
5+
__all__ = ["create_admin_router"]

s3proxy/admin/auth.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Basic auth for admin dashboard."""
2+
3+
import secrets
4+
5+
from fastapi import Depends, HTTPException, status
6+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
7+
8+
security = HTTPBasic(realm="S3Proxy Admin")
9+
10+
_security_dep = Depends(security)
11+
12+
13+
def create_auth_dependency(settings, credentials_store: dict[str, str]):
14+
"""Create a Basic Auth dependency for the admin router."""
15+
if settings.admin_username and settings.admin_password:
16+
valid_username = settings.admin_username
17+
valid_password = settings.admin_password
18+
else:
19+
if not credentials_store:
20+
raise RuntimeError("No credentials configured for admin auth")
21+
valid_username = next(iter(credentials_store.keys()))
22+
valid_password = credentials_store[valid_username]
23+
24+
async def verify(credentials: HTTPBasicCredentials = _security_dep):
25+
username_ok = secrets.compare_digest(credentials.username.encode(), valid_username.encode())
26+
password_ok = secrets.compare_digest(credentials.password.encode(), valid_password.encode())
27+
if not (username_ok and password_ok):
28+
raise HTTPException(
29+
status_code=status.HTTP_401_UNAUTHORIZED,
30+
detail="Invalid credentials",
31+
headers={"WWW-Authenticate": 'Basic realm="S3Proxy Admin"'},
32+
)
33+
return credentials
34+
35+
return verify

s3proxy/admin/collectors.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Data collectors for admin dashboard."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import time
7+
from typing import TYPE_CHECKING
8+
9+
from .. import metrics
10+
from ..state.redis import is_using_redis
11+
12+
if TYPE_CHECKING:
13+
from ..config import Settings
14+
from ..handlers import S3ProxyHandler
15+
16+
17+
def collect_key_status(settings: Settings) -> dict:
18+
"""Collect encryption key status. Never exposes raw key material."""
19+
return {
20+
"kek_fingerprint": hashlib.sha256(settings.kek).hexdigest()[:16],
21+
"algorithm": "AES-256-GCM + AES-KWP",
22+
"dek_tag_name": settings.dektag_name,
23+
}
24+
25+
26+
async def collect_upload_status(handler: S3ProxyHandler) -> dict:
27+
"""Collect active multipart upload status."""
28+
uploads = await handler.multipart_manager.list_active_uploads()
29+
return {
30+
"active_count": len(uploads),
31+
"uploads": uploads,
32+
}
33+
34+
35+
def _read_gauge(gauge) -> float:
36+
"""Read current value from a Prometheus Gauge."""
37+
return gauge._value.get()
38+
39+
40+
def _read_counter(counter) -> float:
41+
"""Read current value from a Prometheus Counter."""
42+
return counter._value.get()
43+
44+
45+
def _read_labeled_counter_sum(counter) -> float:
46+
"""Sum all label combinations for a labeled counter."""
47+
total = 0.0
48+
for sample in counter.collect()[0].samples:
49+
if sample.name.endswith("_total"):
50+
total += sample.value
51+
return total
52+
53+
54+
def _read_labeled_gauge_sum(gauge) -> float:
55+
"""Sum all label combinations for a labeled gauge."""
56+
total = 0.0
57+
for sample in gauge.collect()[0].samples:
58+
total += sample.value
59+
return total
60+
61+
62+
def collect_system_health(start_time: float) -> dict:
63+
"""Collect system health metrics."""
64+
memory_reserved = _read_gauge(metrics.MEMORY_RESERVED_BYTES)
65+
memory_limit = _read_gauge(metrics.MEMORY_LIMIT_BYTES)
66+
usage_pct = round(memory_reserved / memory_limit * 100, 1) if memory_limit > 0 else 0
67+
68+
return {
69+
"memory_reserved_bytes": int(memory_reserved),
70+
"memory_limit_bytes": int(memory_limit),
71+
"memory_usage_pct": usage_pct,
72+
"requests_in_flight": int(_read_labeled_gauge_sum(metrics.REQUESTS_IN_FLIGHT)),
73+
"memory_rejections": int(_read_counter(metrics.MEMORY_REJECTIONS)),
74+
"uptime_seconds": int(time.monotonic() - start_time),
75+
"storage_backend": ("Redis (HA)" if is_using_redis() else "In-memory"),
76+
}
77+
78+
79+
def collect_request_stats() -> dict:
80+
"""Collect request statistics."""
81+
encrypt_ops = 0.0
82+
decrypt_ops = 0.0
83+
for sample in metrics.ENCRYPTION_OPERATIONS.collect()[0].samples:
84+
if sample.name.endswith("_total"):
85+
if sample.labels.get("operation") == "encrypt":
86+
encrypt_ops = sample.value
87+
elif sample.labels.get("operation") == "decrypt":
88+
decrypt_ops = sample.value
89+
90+
return {
91+
"total_requests": int(_read_labeled_counter_sum(metrics.REQUEST_COUNT)),
92+
"encrypt_ops": int(encrypt_ops),
93+
"decrypt_ops": int(decrypt_ops),
94+
"bytes_encrypted": int(_read_counter(metrics.BYTES_ENCRYPTED)),
95+
"bytes_decrypted": int(_read_counter(metrics.BYTES_DECRYPTED)),
96+
}
97+
98+
99+
def _format_bytes(n: int) -> str:
100+
"""Format bytes to human-readable string."""
101+
for unit in ("B", "KB", "MB", "GB", "TB"):
102+
if abs(n) < 1024:
103+
return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
104+
n /= 1024
105+
return f"{n:.1f} PB"
106+
107+
108+
def _format_uptime(seconds: int) -> str:
109+
"""Format seconds to human-readable uptime string."""
110+
days, remainder = divmod(seconds, 86400)
111+
hours, remainder = divmod(remainder, 3600)
112+
minutes, _ = divmod(remainder, 60)
113+
parts = []
114+
if days:
115+
parts.append(f"{days}d")
116+
if hours:
117+
parts.append(f"{hours}h")
118+
parts.append(f"{minutes}m")
119+
return " ".join(parts)
120+
121+
122+
async def collect_all(
123+
settings: Settings,
124+
handler: S3ProxyHandler,
125+
start_time: float,
126+
) -> dict:
127+
"""Collect all dashboard data."""
128+
upload_status = await collect_upload_status(handler)
129+
health = collect_system_health(start_time)
130+
stats = collect_request_stats()
131+
return {
132+
"key_status": collect_key_status(settings),
133+
"upload_status": upload_status,
134+
"system_health": health,
135+
"request_stats": stats,
136+
"formatted": {
137+
"memory_reserved": _format_bytes(health["memory_reserved_bytes"]),
138+
"memory_limit": _format_bytes(health["memory_limit_bytes"]),
139+
"uptime": _format_uptime(health["uptime_seconds"]),
140+
"bytes_encrypted": _format_bytes(stats["bytes_encrypted"]),
141+
"bytes_decrypted": _format_bytes(stats["bytes_decrypted"]),
142+
},
143+
}

s3proxy/admin/router.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Admin dashboard router."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from fastapi import APIRouter, Depends, Request
8+
from fastapi.responses import HTMLResponse, JSONResponse
9+
10+
from .auth import create_auth_dependency
11+
from .collectors import collect_all
12+
from .templates import DASHBOARD_HTML
13+
14+
if TYPE_CHECKING:
15+
from ..config import Settings
16+
17+
18+
def create_admin_router(settings: Settings, credentials_store: dict[str, str]) -> APIRouter:
19+
"""Create the admin dashboard router with auth."""
20+
verify_admin = create_auth_dependency(settings, credentials_store)
21+
router = APIRouter(dependencies=[Depends(verify_admin)])
22+
23+
@router.get("/", response_class=HTMLResponse)
24+
async def dashboard():
25+
return HTMLResponse(DASHBOARD_HTML)
26+
27+
@router.get("/api/status")
28+
async def status(request: Request):
29+
data = await collect_all(
30+
request.app.state.settings,
31+
request.app.state.handler,
32+
request.app.state.start_time,
33+
)
34+
return JSONResponse(data)
35+
36+
return router

s3proxy/admin/templates.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""HTML template for admin dashboard."""
2+
3+
DASHBOARD_HTML = """\
4+
<!DOCTYPE html>
5+
<html lang="en">
6+
<head>
7+
<meta charset="UTF-8">
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
9+
<title>S3Proxy Admin</title>
10+
<style>
11+
*{margin:0;padding:0;box-sizing:border-box}
12+
body{background:#0f1117;color:#c9d1d9;font-family:'SF Mono',Menlo,Consolas,monospace;font-size:14px;padding:24px;max-width:900px;margin:0 auto}
13+
h1{font-size:18px;color:#f0f6fc;margin-bottom:4px}
14+
.subtitle{color:#8b949e;font-size:12px;margin-bottom:24px}
15+
.refresh-badge{display:inline-block;background:#1c2128;border:1px solid #30363d;border-radius:4px;padding:2px 8px;font-size:11px;color:#8b949e}
16+
.section{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:16px}
17+
.section-title{font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px;font-weight:600}
18+
.row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #21262d}
19+
.row:last-child{border-bottom:none}
20+
.label{color:#8b949e}
21+
.value{color:#f0f6fc;font-weight:500}
22+
.bar-container{width:120px;height:8px;background:#21262d;border-radius:4px;display:inline-block;vertical-align:middle;margin-left:8px}
23+
.bar-fill{height:100%;border-radius:4px;transition:width 0.3s}
24+
.bar-green{background:#3fb950}
25+
.bar-yellow{background:#d29922}
26+
.bar-red{background:#f85149}
27+
table{width:100%;border-collapse:collapse;margin-top:8px}
28+
th{text-align:left;color:#8b949e;font-weight:500;padding:6px 8px;border-bottom:1px solid #30363d;font-size:12px}
29+
td{padding:6px 8px;border-bottom:1px solid #21262d;color:#c9d1d9}
30+
.empty{color:#484f58;font-style:italic;padding:12px 0}
31+
.num{font-variant-numeric:tabular-nums}
32+
#status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#3fb950;margin-right:6px}
33+
</style>
34+
</head>
35+
<body>
36+
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24px">
37+
<div><h1><span id="status-dot"></span>S3Proxy Admin</h1><div class="subtitle">Encryption proxy dashboard</div></div>
38+
<div class="refresh-badge">auto-refresh: 10s</div>
39+
</div>
40+
41+
<div class="section">
42+
<div class="section-title">Key Status</div>
43+
<div class="row"><span class="label">KEK Fingerprint</span><span class="value" id="kek-fp"></span></div>
44+
<div class="row"><span class="label">Algorithm</span><span class="value" id="algo"></span></div>
45+
<div class="row"><span class="label">DEK Tag Name</span><span class="value" id="dek-tag"></span></div>
46+
</div>
47+
48+
<div class="section">
49+
<div class="section-title">Active Uploads</div>
50+
<div class="row"><span class="label">Count</span><span class="value num" id="upload-count"></span></div>
51+
<div id="uploads-table"></div>
52+
</div>
53+
54+
<div class="section">
55+
<div class="section-title">System Health</div>
56+
<div class="row"><span class="label">Memory</span><span class="value" id="memory"></span></div>
57+
<div class="row"><span class="label">In-Flight Requests</span><span class="value num" id="in-flight"></span></div>
58+
<div class="row"><span class="label">503 Rejections</span><span class="value num" id="rejections"></span></div>
59+
<div class="row"><span class="label">Uptime</span><span class="value" id="uptime"></span></div>
60+
<div class="row"><span class="label">Storage Backend</span><span class="value" id="storage"></span></div>
61+
</div>
62+
63+
<div class="section">
64+
<div class="section-title">Request Stats</div>
65+
<div class="row"><span class="label">Total Requests</span><span class="value num" id="total-req"></span></div>
66+
<div class="row"><span class="label">Encrypt Ops</span><span class="value num" id="encrypt-ops"></span></div>
67+
<div class="row"><span class="label">Decrypt Ops</span><span class="value num" id="decrypt-ops"></span></div>
68+
<div class="row"><span class="label">Bytes Encrypted</span><span class="value" id="bytes-enc"></span></div>
69+
<div class="row"><span class="label">Bytes Decrypted</span><span class="value" id="bytes-dec"></span></div>
70+
</div>
71+
72+
<script>
73+
function fmt(n){return n.toLocaleString()}
74+
function barClass(pct){return pct>80?'bar-red':pct>50?'bar-yellow':'bar-green'}
75+
function bar(pct){return '<div class="bar-container"><div class="bar-fill '+barClass(pct)+'" style="width:'+Math.min(pct,100)+'%"></div></div>'}
76+
function age(iso){
77+
var s=Math.floor((Date.now()-new Date(iso+'Z'))/1000);
78+
if(s<60)return s+'s';
79+
if(s<3600)return Math.floor(s/60)+'m';
80+
if(s<86400)return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m';
81+
return Math.floor(s/86400)+'d '+Math.floor((s%86400)/3600)+'h';
82+
}
83+
function update(d){
84+
var k=d.key_status,u=d.upload_status,h=d.system_health,r=d.request_stats,f=d.formatted;
85+
document.getElementById('kek-fp').textContent=k.kek_fingerprint;
86+
document.getElementById('algo').textContent=k.algorithm;
87+
document.getElementById('dek-tag').textContent=k.dek_tag_name;
88+
document.getElementById('upload-count').textContent=u.active_count;
89+
var ut=document.getElementById('uploads-table');
90+
if(u.uploads.length===0){ut.innerHTML='<div class="empty">No active uploads</div>';}
91+
else{var h2='<table><tr><th>Bucket</th><th>Key</th><th>Parts</th><th>Size</th><th>Age</th></tr>';
92+
u.uploads.forEach(function(up){h2+='<tr><td>'+up.bucket+'</td><td>'+up.key+'</td><td class="num">'+up.parts_count+'</td><td class="num">'+up.total_plaintext_size+'</td><td>'+age(up.created_at)+'</td></tr>';});
93+
h2+='</table>';ut.innerHTML=h2;}
94+
document.getElementById('memory').innerHTML=f.memory_reserved+' / '+f.memory_limit+' ('+h.memory_usage_pct+'%)'+bar(h.memory_usage_pct);
95+
document.getElementById('in-flight').textContent=fmt(h.requests_in_flight);
96+
document.getElementById('rejections').textContent=fmt(h.memory_rejections);
97+
document.getElementById('uptime').textContent=f.uptime;
98+
document.getElementById('storage').textContent=h.storage_backend;
99+
document.getElementById('total-req').textContent=fmt(r.total_requests);
100+
document.getElementById('encrypt-ops').textContent=fmt(r.encrypt_ops);
101+
document.getElementById('decrypt-ops').textContent=fmt(r.decrypt_ops);
102+
document.getElementById('bytes-enc').textContent=f.bytes_encrypted;
103+
document.getElementById('bytes-dec').textContent=f.bytes_decrypted;
104+
}
105+
function refresh(){
106+
fetch('api/status',{credentials:'same-origin'})
107+
.then(function(r){return r.json()})
108+
.then(update)
109+
.catch(function(){});
110+
}
111+
refresh();
112+
setInterval(refresh,10000);
113+
</script>
114+
</body>
115+
</html>
116+
"""

0 commit comments

Comments
 (0)