Skip to content

Commit eb6b847

Browse files
feat: add brutalist sparkline graphs for throughput and bandwidth history
- RateTracker window extended to 10 min with new history() method - Canvas sparklines under each throughput card (requests, encrypt, decrypt) - Full-width bandwidth section with encrypt/decrypt byte rate sparklines - Retina-aware canvas rendering with devicePixelRatio support - Green (#3fb950) for encrypt, blue (#58a6ff) for decrypt
1 parent 9f3e7a4 commit eb6b847

File tree

3 files changed

+109
-7
lines changed

3 files changed

+109
-7
lines changed

s3proxy/admin/collectors.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
class RateTracker:
3434
"""Tracks counter snapshots over a sliding window to compute per-minute rates."""
3535

36-
def __init__(self, window_seconds: int = 300):
36+
def __init__(self, window_seconds: int = 600):
3737
self._window = window_seconds
3838
self._snapshots: deque[tuple[float, dict[str, float]]] = deque()
3939

@@ -55,8 +55,26 @@ def rate_per_minute(self, key: str) -> float:
5555
delta = newest_vals.get(key, 0) - oldest_vals.get(key, 0)
5656
return max(0.0, delta / elapsed * 60)
5757

58+
def history(self, key: str, max_points: int = 60) -> list[float]:
59+
"""Return per-minute rate history as a list of floats for sparklines."""
60+
if len(self._snapshots) < 2:
61+
return []
62+
rates: list[float] = []
63+
for i in range(1, len(self._snapshots)):
64+
prev_ts, prev_vals = self._snapshots[i - 1]
65+
curr_ts, curr_vals = self._snapshots[i]
66+
elapsed = curr_ts - prev_ts
67+
if elapsed < 0.1:
68+
continue
69+
delta = curr_vals.get(key, 0) - prev_vals.get(key, 0)
70+
rates.append(round(max(0.0, delta / elapsed * 60), 1))
71+
if len(rates) > max_points:
72+
step = len(rates) / max_points
73+
rates = [rates[int(i * step)] for i in range(max_points)]
74+
return rates
75+
5876

59-
_rate_tracker = RateTracker(window_seconds=300)
77+
_rate_tracker = RateTracker(window_seconds=600)
6078

6179

6280
# ---------------------------------------------------------------------------
@@ -178,6 +196,13 @@ def collect_throughput() -> dict:
178196
"errors_5xx_per_min": round(_rate_tracker.rate_per_minute("errors_5xx"), 1),
179197
"errors_503_per_min": round(_rate_tracker.rate_per_minute("errors_503"), 1),
180198
},
199+
"history": {
200+
"requests_per_min": _rate_tracker.history("requests"),
201+
"encrypt_per_min": _rate_tracker.history("encrypt_ops"),
202+
"decrypt_per_min": _rate_tracker.history("decrypt_ops"),
203+
"bytes_encrypted_per_min": _rate_tracker.history("bytes_encrypted"),
204+
"bytes_decrypted_per_min": _rate_tracker.history("bytes_decrypted"),
205+
},
181206
}
182207

183208

s3proxy/admin/templates.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@
7070
.spinner{width:8px;height:8px;border:1.5px solid #30363d;border-top-color:#58a6ff;border-radius:50%;display:inline-block}
7171
.spinner.active{animation:spin 0.6s linear infinite}
7272
@keyframes spin{to{transform:rotate(360deg)}}
73+
74+
canvas.sparkline{display:block;width:100%;height:24px;margin-top:6px}
75+
.bw-row{display:flex;align-items:center;gap:12px;padding:5px 0;border-bottom:1px solid #21262d}
76+
.bw-row:last-child{border-bottom:none}
77+
.bw-label{color:#8b949e;font-size:11px;width:56px;flex-shrink:0}
78+
canvas.bw-spark{display:block;flex:1;height:32px}
79+
.bw-val{color:#f0f6fc;font-size:12px;font-weight:500;width:100px;text-align:right;flex-shrink:0;font-variant-numeric:tabular-nums}
7380
</style>
7481
</head>
7582
<body>
@@ -93,7 +100,7 @@
93100
<div class="section-title">Health <span class="section-tag">this pod</span></div>
94101
<div class="row"><span class="label">Memory</span><span class="value" id="memory">-</span></div>
95102
<div class="row"><span class="label">In-Flight</span><span class="value" id="in-flight">-</span></div>
96-
<div class="section-title" style="margin-top:10px;margin-bottom:6px">Errors <span class="section-tag">5 min rate</span></div>
103+
<div class="section-title" style="margin-top:10px;margin-bottom:6px">Errors <span class="section-tag">10 min rate</span></div>
97104
<div class="errors-row">
98105
<span class="err-item">4xx <span class="err-val" id="err-4xx">0</span>/min</span>
99106
<span class="err-item">5xx <span class="err-val" id="err-5xx">0</span>/min</span>
@@ -102,12 +109,18 @@
102109
</div>
103110
104111
<div class="section">
105-
<div class="section-title">Throughput <span class="section-tag">this pod &middot; 5 min rate</span></div>
112+
<div class="section-title">Throughput <span class="section-tag">this pod &middot; 10 min</span></div>
106113
<div class="throughput-grid">
107-
<div class="tp-item"><div class="tp-num" id="tp-req">0</div><div class="tp-unit">/min</div><div class="tp-label">requests</div></div>
108-
<div class="tp-item"><div class="tp-num" id="tp-enc">0</div><div class="tp-unit">/min &middot; <span id="tp-enc-bytes">0 B</span>/min</div><div class="tp-label">encrypt</div></div>
109-
<div class="tp-item"><div class="tp-num" id="tp-dec">0</div><div class="tp-unit">/min &middot; <span id="tp-dec-bytes">0 B</span>/min</div><div class="tp-label">decrypt</div></div>
114+
<div class="tp-item"><div class="tp-num" id="tp-req">0</div><div class="tp-unit">/min</div><div class="tp-label">requests</div><canvas class="sparkline" id="spark-req" height="24"></canvas></div>
115+
<div class="tp-item"><div class="tp-num" id="tp-enc">0</div><div class="tp-unit">/min &middot; <span id="tp-enc-bytes">0 B</span>/min</div><div class="tp-label">encrypt</div><canvas class="sparkline" id="spark-enc" height="24"></canvas></div>
116+
<div class="tp-item"><div class="tp-num" id="tp-dec">0</div><div class="tp-unit">/min &middot; <span id="tp-dec-bytes">0 B</span>/min</div><div class="tp-label">decrypt</div><canvas class="sparkline" id="spark-dec" height="24"></canvas></div>
117+
</div>
110118
</div>
119+
120+
<div class="section">
121+
<div class="section-title">Bandwidth <span class="section-tag">this pod &middot; 10 min</span></div>
122+
<div class="bw-row"><span class="bw-label">encrypt</span><canvas class="bw-spark" id="spark-bw-enc" height="32"></canvas><span class="bw-val" id="bw-enc-val">0 B/min</span></div>
123+
<div class="bw-row"><span class="bw-label">decrypt</span><canvas class="bw-spark" id="spark-bw-dec" height="32"></canvas><span class="bw-val" id="bw-dec-val">0 B/min</span></div>
111124
</div>
112125
113126
<div class="section">
@@ -130,6 +143,27 @@
130143
return Math.floor(s/86400)+'d '+Math.floor((s%86400)/3600)+'h';
131144
}
132145
146+
function drawSpark(id,data,color){
147+
var c=document.getElementById(id);
148+
if(!c)return;
149+
var dpr=window.devicePixelRatio||1;
150+
var w=c.offsetWidth,h=parseInt(c.getAttribute('height'))||24;
151+
c.width=w*dpr;c.height=h*dpr;
152+
var ctx=c.getContext('2d');
153+
ctx.scale(dpr,dpr);
154+
if(!data||data.length<2)return;
155+
var mx=0;
156+
for(var i=0;i<data.length;i++)if(data[i]>mx)mx=data[i];
157+
if(!mx)return;
158+
var bw=Math.max(1,Math.floor(w/data.length)-1);
159+
ctx.fillStyle=color||'#3fb950';
160+
for(var i=0;i<data.length;i++){
161+
var bh=Math.round((data[i]/mx)*(h-2));
162+
if(data[i]>0&&bh<1)bh=1;
163+
ctx.fillRect(i*(bw+1),h-bh,bw,bh);
164+
}
165+
}
166+
133167
function updatePods(pods, currentPod){
134168
var grid=document.getElementById('pods-grid');
135169
if(!pods||pods.length<=1){grid.innerHTML='';return;}
@@ -176,6 +210,15 @@
176210
document.getElementById('tp-enc-bytes').textContent=f.bytes_encrypted_per_min||'0 B';
177211
document.getElementById('tp-dec-bytes').textContent=f.bytes_decrypted_per_min||'0 B';
178212
213+
var hist=(d.throughput||{}).history||{};
214+
drawSpark('spark-req',hist.requests_per_min,'#3fb950');
215+
drawSpark('spark-enc',hist.encrypt_per_min,'#3fb950');
216+
drawSpark('spark-dec',hist.decrypt_per_min,'#58a6ff');
217+
drawSpark('spark-bw-enc',hist.bytes_encrypted_per_min,'#3fb950');
218+
drawSpark('spark-bw-dec',hist.bytes_decrypted_per_min,'#58a6ff');
219+
document.getElementById('bw-enc-val').textContent=(f.bytes_encrypted_per_min||'0 B')+'/min';
220+
document.getElementById('bw-dec-val').textContent=(f.bytes_decrypted_per_min||'0 B')+'/min';
221+
179222
document.getElementById('upload-num').textContent=u.active_count||0;
180223
document.getElementById('uploads-source').textContent=pod.storage_backend==='In-memory'?'this pod':'cluster \\u00b7 Redis';
181224
var ut=document.getElementById('uploads-table');

tests/unit/test_admin.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def test_contains_expected_sections(self, client, admin_credentials):
161161
assert "S3Proxy Admin" in html
162162
assert "Health" in html
163163
assert "Throughput" in html
164+
assert "Bandwidth" in html
164165
assert "Active Uploads" in html
165166

166167
def test_no_sensitive_data_in_html(self, client, admin_credentials, admin_settings):
@@ -288,6 +289,10 @@ def test_throughput_keys(self):
288289
assert "errors_4xx_per_min" in rates
289290
assert "errors_5xx_per_min" in rates
290291
assert "errors_503_per_min" in rates
292+
history = result["history"]
293+
assert "requests_per_min" in history
294+
assert "bytes_encrypted_per_min" in history
295+
assert "bytes_decrypted_per_min" in history
291296

292297
def test_format_bytes(self):
293298
assert _format_bytes(0) == "0 B"
@@ -344,6 +349,35 @@ def test_pruning(self):
344349
# Old entries beyond window + 10s buffer should be pruned
345350
assert len(tracker._snapshots) < 50
346351

352+
def test_history_empty(self):
353+
tracker = RateTracker()
354+
assert tracker.history("requests") == []
355+
356+
def test_history_single_snapshot(self):
357+
tracker = RateTracker()
358+
tracker.record({"requests": 100})
359+
assert tracker.history("requests") == []
360+
361+
def test_history_computation(self):
362+
tracker = RateTracker()
363+
tracker._snapshots.clear()
364+
# 3 snapshots 60s apart: 100→200→400
365+
tracker._snapshots.append((1000.0, {"requests": 100}))
366+
tracker._snapshots.append((1060.0, {"requests": 200}))
367+
tracker._snapshots.append((1120.0, {"requests": 400}))
368+
hist = tracker.history("requests")
369+
assert len(hist) == 2
370+
assert hist[0] == 100.0 # (200-100)/60*60
371+
assert hist[1] == 200.0 # (400-200)/60*60
372+
373+
def test_history_downsampling(self):
374+
tracker = RateTracker()
375+
tracker._snapshots.clear()
376+
for i in range(101):
377+
tracker._snapshots.append((1000.0 + i * 3, {"x": float(i * 10)}))
378+
hist = tracker.history("x", max_points=20)
379+
assert len(hist) == 20
380+
347381

348382
# ============================================================================
349383
# State Store list_keys Tests

0 commit comments

Comments
 (0)