feat(agent): Status shows smoothed jobs/min + downloads/min (replace jumpy gauges)
The buffer / on-GPU / downloader counts flip many times a second, so a 3s
status poll only ever samples noise — the tiles looked frozen (same value
twice) or random (wildly different), reading as "the Status section doesn't
update" when the backend was in fact live (operator-flagged 2026-07-01).
Replace the three instantaneous gauge tiles with two derived RATE tiles:
- jobs / min — GPU throughput, from the monotonic `processed` counter
- downloads / min — fetch throughput, from a new monotonic `downloaded`
counter (bumped when a job is decoded into the buffer)
Together they also show pipeline balance (dl/min > j/min ⇒ GPU-bound; the
reverse ⇒ GPU starved). Both are EWMA-smoothed over the poll deltas, clamped
at 0 (agent restart resets the counters), and skip a backgrounded-tab gap.
The still-useful instantaneous state is demoted, not lost: buffer stays as
the occupancy bar; downloaders/consumers/on-GPU move to the sub-line. `waited
out` (transient) gets promoted to a tile.
backend: worker.status() gains `downloaded`; `_bump(downloaded=)`.
frontend: retiled Status + rate math in applyStatus; VERSION → .7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+31
-9
@@ -17,7 +17,7 @@ from .worker import Worker
|
||||
# Bump on every agent change. The page embeds this and /status reports it; the UI
|
||||
# warns to reload when they differ — so a stale browser-cached page can't be
|
||||
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
|
||||
VERSION = "2026-07-01.6 · stream videos via ffmpeg-from-URL (no full download)"
|
||||
VERSION = "2026-07-01.7 · status: smoothed jobs/min + downloads/min rates"
|
||||
|
||||
logbuf.install()
|
||||
cfg = Config.from_env()
|
||||
@@ -218,11 +218,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
<div class=card-h>Status</div>
|
||||
<div class=tiles>
|
||||
<div class=tile><div class=n id=state>—</div><div class=l>state</div></div>
|
||||
<div class=tile><div class=n id=dln>0</div><div class=l>downloaders</div></div>
|
||||
<div class=tile><div class=n id=bufn>—</div><div class=l>buffer</div></div>
|
||||
<div class=tile><div class=n id=active>0</div><div class=l>on GPU</div></div>
|
||||
<div class=tile><div class=n id=jpm>—</div><div class=l>jobs / min</div></div>
|
||||
<div class=tile><div class=n id=dpm>—</div><div class=l>downloads / min</div></div>
|
||||
<div class=tile><div class="n ok" id=done>0</div><div class=l>processed</div></div>
|
||||
<div class=tile><div class=n id=err>0</div><div class=l>errors</div></div>
|
||||
<div class=tile><div class=n id=waited>0</div><div class=l>waited out</div></div>
|
||||
</div>
|
||||
<div class=meters>
|
||||
<div class=meter><div class=meter-h><span>GPU util</span><b id=utillbl>—</b></div>
|
||||
@@ -232,7 +232,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
<div class=meter><div class=meter-h><span>buffer occupancy</span><b id=buflbl>—</b></div>
|
||||
<div class=bar><i id=bufbar></i></div></div>
|
||||
</div>
|
||||
<div class=queue id=pipe>consumers — · waited out 0</div>
|
||||
<div class=queue id=pipe>downloaders — · consumers — · on GPU 0</div>
|
||||
<div class=queue id=queue>queue —</div>
|
||||
</section>
|
||||
|
||||
@@ -246,6 +246,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
<script>
|
||||
const PAGE_BUILD="__BUILD__"
|
||||
let CAP=8
|
||||
// Smoothed derived rates (jobs/min, downloads/min) + the deltas they're built
|
||||
// from. The raw buffer/on-GPU/downloader counts change many times a second, so
|
||||
// a poll only ever samples noise; these EWMA the two monotonic counters into a
|
||||
// stable, readable throughput instead. Reset to null on load → re-converge.
|
||||
let jpmv=null, dpmv=null, lastP=null, lastD=null, lastRateT=null
|
||||
// Optimistic transitional state on click, then apply the POST's own status
|
||||
// response (it returns worker.status()) for instant feedback — don't wait on the
|
||||
// separate /status poll, which can lag behind the curator queue call.
|
||||
@@ -288,12 +293,29 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
const draining=!running && s.active>0
|
||||
state.textContent=draining?'stopping':s.state
|
||||
state.className='n'+(draining?' busy':'')
|
||||
dln.textContent=(s.downloaders!=null?s.downloaders:'—')
|
||||
bufn.textContent=(s.buffer!=null?(s.buffer+'/'+s.buffer_max):'—')
|
||||
active.textContent=s.active; active.className='n'+(s.active>0?' busy':'')
|
||||
// Derived rates over the poll deltas — j/min ≈ GPU throughput, dl/min ≈ fetch
|
||||
// throughput. EWMA-smoothed so a lull between jobs doesn't blank them; clamped
|
||||
// at 0 so an agent restart (counters reset to 0 → negative delta) can't spike
|
||||
// them; the dt<30s guard drops a backgrounded-tab gap that would read as huge.
|
||||
const now=performance.now()
|
||||
if(lastRateT!=null && s.processed!=null){
|
||||
const dt=(now-lastRateT)/1000
|
||||
if(dt>0.5 && dt<30){
|
||||
const jp=Math.max(0,60*(s.processed-lastP)/dt)
|
||||
const dp=Math.max(0,60*((s.downloaded||0)-lastD)/dt)
|
||||
jpmv=(jpmv==null)?jp:0.4*jp+0.6*jpmv
|
||||
dpmv=(dpmv==null)?dp:0.4*dp+0.6*dpmv
|
||||
}
|
||||
}
|
||||
lastP=s.processed; lastD=(s.downloaded||0); lastRateT=now
|
||||
jpm.textContent=(jpmv==null)?'—':Math.round(jpmv)
|
||||
dpm.textContent=(dpmv==null)?'—':Math.round(dpmv)
|
||||
done.textContent=s.processed
|
||||
err.textContent=s.errors; err.className='n'+(s.errors>0?' warn':'')
|
||||
pipe.textContent='consumers '+(s.consumers!=null?s.consumers:'—')+' · waited out '+(s.transient||0)
|
||||
waited.textContent=s.transient||0
|
||||
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
|
||||
// as live churn rather than a "broken" headline metric.
|
||||
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'—')+' · consumers '+(s.consumers!=null?s.consumers:'—')+' · on GPU '+(s.active||0)
|
||||
// Buffer occupancy bar (also driven here so it tracks the /status cadence).
|
||||
if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max)
|
||||
buflbl.textContent=s.buffer+' / '+s.buffer_max; bufbar.style.width=p+'%' }
|
||||
|
||||
@@ -162,6 +162,10 @@ class Worker:
|
||||
self._held: set[int] = set()
|
||||
self._held_lock = threading.Lock()
|
||||
self.processed = 0
|
||||
self.downloaded = 0 # jobs fetched+decoded into the buffer (monotonic)
|
||||
# — the UI derives a smoothed downloads/min from it,
|
||||
# since the instantaneous buffer/active gauges move
|
||||
# faster than any sane poll can show.
|
||||
self.errors = 0
|
||||
self.transient = 0 # jobs handed back due to a server outage (NOT
|
||||
# failed) — the "waiting out curator" counter
|
||||
@@ -425,13 +429,15 @@ class Worker:
|
||||
"buffer_max": BUFFER_MAX,
|
||||
"active": self._active,
|
||||
"processed": self.processed,
|
||||
"downloaded": self.downloaded,
|
||||
"errors": self.errors,
|
||||
"transient": self.transient,
|
||||
}
|
||||
|
||||
def _bump(self, *, processed=0, errors=0, active=0, transient=0):
|
||||
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
|
||||
with self._lock:
|
||||
self.processed += processed
|
||||
self.downloaded += downloaded
|
||||
self.errors += errors
|
||||
self.transient += transient
|
||||
# Clamp at 0: a Stop resets _active to 0, so a consumer that was
|
||||
@@ -497,6 +503,7 @@ class Worker:
|
||||
continue
|
||||
# Blocks on a full buffer (backpressure) but wakes promptly on stop.
|
||||
if self._put((job, frames), stop_evt):
|
||||
self._bump(downloaded=1) # fetched+decoded into the buffer
|
||||
owned.remove(jid) # ownership handed to the buffer/consumer
|
||||
else:
|
||||
break # stopped while waiting for buffer space
|
||||
|
||||
Reference in New Issue
Block a user