Files
FabledCurator/agent/fc_agent/app.py
T
bvandeusenandClaude Opus 5 e1cd629196
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 4s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 1m2s
CI and images / build-agent (push) Successful in 10m7s
CI and images / promote (push) Skipped
feat: the agent derives its version instead of being asked to remember it (4386)
Operator: the agent's build string could not identify the agent. VERSION was a
literal in app.py an author was meant to bump, and nobody did — the September
image printed the same "2026-07-17.1" as the July one, so the one surface
meant to answer "did my pull work?" answered the same either way.

Nothing new was needed. scripts/artifacts.sh has derived a version per
artifact since milestone 313, and build-agent has been computing the agent's
on every run and printing it to the log. The image just never carried it.

Three values, never folded together (rule 149):

  FC_VERSION   YYYY.MM.DD.HHMM from the COMMIT its shipped files last changed
               in — identical on dev and main for the same source, which is
               what makes "am I running production's code?" answerable.
  FC_CHANNEL   a sibling field, never a suffix inside the name.
  FC_REVISION  the 12-char sha; the same string as the fc.revision LABEL, so
               the image and the registry cannot disagree about which commit
               this is.

The page SHOWS the version and COMPARES the revision. Those were one value
before, which is how a version acquires a second job and then cannot be
changed without breaking the reload banner. An unstamped local build reads
`unknown` and compares `local` — absent rather than empty, one spelling of
"cannot say".

scripts/artifacts.sh joins the AGENT path set in the same commit, and it had
to: a version has no backstop. A revision that is computed differently stops
matching the published label and forces a rebuild, so it self-corrects; a
version is compared against nothing, so a change to cmd_version alone would
leave the agent publishing the old format with nothing to contradict it. That
is #3202's finding, and the agent was rightly exempt only while it had no
version of its own. tests/test_artifact_paths.py pins it.

Also corrects two build.yml comments claiming agent/ had not changed since
2026-07-17. Both were already false — it changed 2026-09-23 — and one of them
is the stated rationale for the force_build escape hatch. Rewritten without
dates: how long an artifact has been quiet is a `git log` question, and its
answer in a comment is wrong the next time anyone commits (lesson #4383).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-24 07:07:31 -04:00

427 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI control surface for the agent (served on localhost).
Start / stop the download→GPU pipeline, tune the downloader count live (the
workload is download-bound, so downloaders are the dial that trades desktop
bandwidth for throughput), and watch GPU load + buffer occupancy + progress +
the server-side queue. Config is env-seeded; the downloader count is adjustable
here on the fly (GPU consumers autoscale between 1 and 2 on their own).
"""
import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from . import logbuf
from .build_info import FC_CHANNEL, FC_REVISION, FC_VERSION, build_id, display_version
from .config import Config
from .gpu import read_gpu
from .worker import Worker
log = logging.getLogger("fc_agent.app")
# DERIVED at image build time, not hand-maintained — see build_info. This was a
# literal an author was asked to bump on every agent change, and the September
# image printed the same "2026-07-17.1" as the July one, so the surface meant to
# answer "did my pull work?" answered the same either way.
#
# Two values with two jobs, kept apart (rule 149): the page SHOWS the version
# and COMPARES the build id. /status reports both, plus the raw fields, so a
# reader never has to take a formatted string apart to get at one of them.
logbuf.install()
cfg = Config.from_env()
worker = Worker(cfg)
app = FastAPI(title="FabledCurator GPU agent")
@app.middleware("http")
async def _no_store(request, call_next):
# The control page is a static string and the status/gpu/logs polls are
# live data — never let the browser cache either, or a freshly-pulled agent
# image still shows the OLD UI until a hard refresh (operator-flagged
# 2026-06-30).
resp = await call_next(request)
resp.headers["Cache-Control"] = "no-store"
return resp
@app.on_event("startup")
def _maybe_autostart() -> None:
# With AUTO_START set, a container restart (host reboot, or `restart:
# unless-stopped` after a crash) resumes the worker on its own — the slots
# then ride out a still-down curator via lease backoff. Lets the agent
# survive a redeploy with nobody at the desktop to click Start.
if cfg.auto_start and cfg.token:
worker.start()
@app.get("/", response_class=HTMLResponse)
def index() -> str:
# Two substitutions, not one: `__VERSION__` is what a person reads in the
# meta line, `__BUILD_ID__` is what the script compares against /status to
# notice the page is a cached copy from a previous build.
return (
_PAGE
.replace("__VERSION__", display_version())
.replace("__BUILD_ID__", build_id())
)
@app.post("/start")
def start():
log.info("UI: Start button pressed") # the press; worker logs the transition
worker.start()
return JSONResponse(worker.status())
@app.post("/stop")
def stop():
log.info("UI: Stop button pressed")
worker.stop()
return JSONResponse(worker.status())
@app.post("/concurrency")
async def concurrency(request: Request):
body = await request.json()
worker.set_concurrency(int(body.get("value", 1)))
return JSONResponse(worker.status())
@app.post("/auto")
async def auto(request: Request):
body = await request.json()
worker.set_auto(bool(body.get("value", True)))
return JSONResponse(worker.status())
@app.post("/bandwidth")
async def bandwidth(request: Request):
body = await request.json()
worker.set_bandwidth(float(body.get("value", 0)))
return JSONResponse(worker.status())
@app.get("/gpu")
def gpu():
# GPU meters poll this on their own fast cadence. It only reads local
# nvidia-smi — no curator round-trip — so the util/VRAM bars stay live even
# when /status is slow waiting on the (sometimes busy) curator queue call.
g = read_gpu() or {}
us = worker.util_smooth()
if us is not None:
g["util_smooth"] = round(us, 1) # autoscaler's EWMA — the UI bar tracks this
return JSONResponse(g)
@app.get("/logs")
def logs():
return JSONResponse({"lines": list(logbuf.LINES)})
@app.get("/status")
def status():
# Pure in-memory read: worker.status() is lock-free and the queue snapshot is
# kept fresh by a background poller — NO inline curator call, so this can't
# stall the status view when curator is buried under a big backlog.
worker.note_ui() # a browser is watching → keep the queue snapshot warm
s = worker.status()
s["fc_url"] = cfg.fc_url
s["configured"] = bool(cfg.token)
s["queue"] = worker.latest_queue()
# `build` is the comparison token the page checks — see build_info.
# `version`/`channel`/`revision` ride BESIDE it rather than inside it, so a
# reader wanting the version never has to parse it back out of something
# else. Absent rather than empty when the image carries no stamp.
s["build"] = build_id()
s["version"] = FC_VERSION or None
s["channel"] = FC_CHANNEL or None
s["revision"] = FC_REVISION or None
return JSONResponse(s)
_PAGE = """<!doctype html><html><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>FabledCurator · GPU agent</title>
<style>
:root{--bg:#0f1216;--panel:#181c22;--panel2:#1e232b;--bd:#2a313b;--fg:#e9edf2;
--mut:#8b97a6;--acc:#e8923a;--grn:#46c46a;--red:#e8584d;--amb:#e8b23a}
*{box-sizing:border-box}
body{font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;
background:radial-gradient(1200px 600px at 50% -10%,#1a2029,#0f1216);color:var(--fg)}
.wrap{max-width:820px;margin:0 auto;padding:28px 20px 28px;height:100vh;
box-sizing:border-box;overflow:hidden;display:flex;flex-direction:column}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
.brand{display:flex;align-items:center;gap:10px;font-size:19px;font-weight:700;letter-spacing:.2px}
.logo{color:var(--acc);font-size:20px}
.brand .sub{color:var(--mut);font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:.12em}
.conn{display:flex;align-items:center;gap:8px;color:var(--mut);font-size:13px;font-weight:600}
.dot{width:9px;height:9px;border-radius:50%;background:var(--mut);box-shadow:0 0 0 0 rgba(0,0,0,0)}
.dot.green{background:var(--grn);box-shadow:0 0 10px 1px rgba(70,196,106,.5)}
.dot.amber{background:var(--amb)} .dot.red{background:var(--red)}
.meta{color:var(--mut);margin:0 0 18px;font-size:13px}
code{background:#11151a;border:1px solid var(--bd);padding:2px 7px;border-radius:6px;
font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#cdd6e0}
.card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--bd);
border-radius:14px;padding:16px 18px;margin-bottom:14px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset}
.card-h{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;
color:var(--mut);margin-bottom:14px}
.controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.spacer{flex:1}
.btn{font:600 14px system-ui;padding:.5rem 1rem;border:1px solid transparent;border-radius:9px;
cursor:pointer;color:#fff;transition:.12s}
.btn:hover{transform:translateY(-1px)}
.btn[disabled]{opacity:.45;pointer-events:none;transform:none}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.tile .n.busy{color:var(--acc);animation:pulse 1s ease-in-out infinite}
.btn.start{background:linear-gradient(180deg,#2f9c4c,#247a3c)}
.btn.stop{background:linear-gradient(180deg,#3a3f48,#2a2f37);color:#e9edf2;border-color:var(--bd)}
.switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-weight:600;user-select:none}
.switch input{display:none}
.switch .track{width:38px;height:22px;border-radius:11px;background:#2a313b;position:relative;transition:.15s}
.switch .track:after{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;
background:#cdd6e0;transition:.15s}
.switch input:checked+.track{background:var(--acc)}
.switch input:checked+.track:after{transform:translateX(16px);background:#fff}
.stepper{display:inline-flex;align-items:center;gap:6px}
.step{background:#262c34;color:var(--fg);border:1px solid var(--bd);border-radius:8px;
width:30px;height:32px;font:700 16px system-ui;cursor:pointer}
.step:hover{border-color:var(--acc)}
#conc,#bw{width:3.4rem;height:32px;text-align:center;font:700 16px system-ui;background:#11151a;
color:var(--fg);border:1px solid var(--bd);border-radius:8px}
.unit{color:var(--mut);font-size:12px;font-weight:600}
.hint{color:var(--mut);font-size:12px;margin-top:12px}
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
.tile{background:#13171d;border:1px solid var(--bd);border-radius:10px;padding:12px 8px;text-align:center}
.tile .n{font:800 22px ui-monospace,monospace;line-height:1.1}
.tile .n.warn{color:var(--red)} .tile .n.ok{color:var(--grn)}
.tile .l{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-top:4px}
.meters{display:flex;flex-direction:column;gap:10px;margin-bottom:14px}
.meter-h{display:flex;justify-content:space-between;font-size:12px;color:var(--mut);margin-bottom:4px}
.meter-h b{color:var(--fg);font-variant-numeric:tabular-nums}
.bar{height:9px;border-radius:5px;background:#11151a;border:1px solid var(--bd);overflow:hidden}
.bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,#3a7d57,var(--grn));transition:width .4s}
#utilbar{background:linear-gradient(90deg,#9a5a1f,var(--acc))}
#bufbar{background:linear-gradient(90deg,#2f5a9a,#4a86d8)}
.queue{font:13px ui-monospace,monospace;color:var(--mut)}
.banner{margin:0 0 14px;padding:.7rem .9rem;border-radius:10px;background:#3a2f12;
border:1px solid #5a4a17;color:#ffd98a;font-size:13px}
.logs-h{display:flex;align-items:center;justify-content:space-between}
.grow{flex:1;display:flex;flex-direction:column;min-height:0}
.grow .logs{flex:1;min-height:0}
.copybtn{font:600 11px system-ui;letter-spacing:.04em;text-transform:uppercase;
background:#262c34;color:var(--fg);border:1px solid var(--bd);border-radius:7px;
padding:5px 11px;cursor:pointer}
.copybtn:hover{border-color:var(--acc)}
.logs{margin:0;background:#0b0e12;border:1px solid var(--bd);border-radius:10px;padding:12px;
overflow:auto;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;
color:#b9c4d0;white-space:pre-wrap;word-break:break-word}
</style></head><body>
<div class=wrap>
<header>
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
<div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
</header>
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__VERSION__</code></p>
<div id=verbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3">
a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
</div>
<div id=banner class=banner style=display:none>
curator unreachable — holding work + retrying, resumes on its own (no restart needed)
</div>
<section class=card>
<div class=card-h>Control</div>
<div class=controls>
<button class="btn start" id=startbtn onclick=act('start')>▶ Start</button>
<button class="btn stop" id=stopbtn onclick=act('stop')>■ Stop</button>
<div class=spacer></div>
<label class=switch><input type=checkbox id=autochk onchange="setauto(this.checked)"><span class=track></span>Auto</label>
<div class=stepper>
<button class=step onclick=setc(-1)></button>
<input id=conc type=number min=1 value=1 onchange="setv(this.value)">
<button class=step onclick=setc(1)>+</button>
</div>
<div class=stepper title="aggregate download cap, downloads + video streams combined — 0 = unlimited">
<input id=bw type=number min=0 step=1 value=8 onchange="setbw(this.value)">
<span class=unit>MB/s</span>
</div>
</div>
<div class=hint id=conchint>auto-tuning downloaders to keep the GPU fed · max 8</div>
</section>
<section class=card>
<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=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>
<div class=bar><i id=utilbar></i></div></div>
<div class=meter><div class=meter-h><span>VRAM</span><b id=vramlbl>—</b></div>
<div class=bar><i id=gpubar></i></div></div>
<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>downloaders — · consumers — · on GPU 0</div>
<div class=queue id=queue>queue —</div>
</section>
<section class="card grow">
<div class="card-h logs-h">Logs
<button class=copybtn id=copybtn onclick=copyLogs()>Copy</button>
</div>
<pre class=logs id=logs>waiting for activity…</pre>
</section>
</div>
<script>
const PAGE_BUILD="__BUILD_ID__"
let CAP=8
// 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.
async function act(p){
pending(p==='start'?'starting':'stopping')
// Abort a slow POST after 8s so the buttons never stay stuck — the periodic
// /status refresh (now always fast) recovers the true state either way.
const ac=new AbortController(); const to=setTimeout(()=>ac.abort(),8000)
try{ applyStatus(await (await fetch('/'+p,{method:'POST',signal:ac.signal})).json()) }
catch{ refresh() /* on abort/error, repaint the real state from /status */ }
finally{ clearTimeout(to) }
}
function pending(label){
// Instant optimistic feedback on click; applyStatus (POST response, then the
// periodic poll) then owns the real state + which buttons are enabled.
state.textContent=label; state.className='n busy'
dot.className='dot amber'
startbtn.disabled=true; stopbtn.disabled=true
}
function setc(d){ if(conc.disabled)return; setv((parseInt(conc.value||'1'))+d) }
async function setv(v){
v=Math.max(1,Math.min(CAP,parseInt(v)||1)); conc.value=v
await fetch('/concurrency',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:v})});refresh()
}
async function setauto(on){
await fetch('/auto',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:on})});refresh()
}
async function setbw(v){
v=Math.max(0,parseFloat(v)||0); bw.value=v
await fetch('/bandwidth',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:v})});refresh()
}
async function refresh(){
let s; try{ s=await (await fetch('/status')).json() }catch{ return }
applyStatus(s)
}
function applyStatus(s){
// NB: don't write a separate `capn` element here — conchint.textContent below
// rewrites the whole hint (incl. the max), and any child element nested in it
// would be destroyed by that write, breaking the NEXT applyStatus call.
CAP=s.max_concurrency||8
// The backend owns the state now (stopped|starting|running|stopping) and drives
// every transition, so the pill is always truthful — no client-side guessing
// from active>0, which used to wedge on "stopping" forever.
const st=s.state||'stopped'
const running=st==='running'
const busy=(st==='starting'||st==='stopping')
// Stale-page guard: if the server is a newer build than this page, the cached
// controls may misbehave — tell the operator to reload.
if(s.build && s.build!==PAGE_BUILD) verbanner.style.display='block'
state.textContent=st
state.className='n'+(busy?' busy':'')
// Buttons follow the real state so you can't fight a transition: Start only
// from stopped; Stop only while up; both disabled through "stopping" until the
// backend truthfully lands on "stopped".
startbtn.disabled=(st!=='stopped')
stopbtn.disabled=!(running||st==='starting')
// Throughput rates arrive READY from the backend (jobs/min ≈ GPU throughput,
// dl/min ≈ fetch throughput), computed there on a fixed cadence — so they show
// a real number no matter how often this tab polls (a backgrounded tab throttles
// its timers, which used to leave a client-side delta-rate blank forever).
jpm.textContent=(s.jobs_per_min!=null)?Math.round(s.jobs_per_min):'—'
dpm.textContent=(s.downloads_per_min!=null)?Math.round(s.downloads_per_min):'—'
done.textContent=s.processed
err.textContent=s.errors; err.className='n'+(s.errors>0?' warn':'')
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.
// '=== false' (not falsy) so a stale page that doesn't send models_loaded shows
// nothing; when the idle monitor unloads, the VRAM meter drops alongside this.
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'—')+' · consumers '+(s.consumers!=null?s.consumers:'—')+' · on GPU '+(s.active||0)
+' · net '+(s.net_mb_s!=null?s.net_mb_s.toFixed(1):'—')+' MB/s'
+(s.bandwidth_limit_mb_s>0?(' / cap '+s.bandwidth_limit_mb_s):'')
+(s.models_loaded===false?' · GPU models unloaded (idle — reload on next job)':'')
if(document.activeElement!==bw && s.bandwidth_limit_mb_s!=null) bw.value=s.bandwidth_limit_mb_s
// 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+'%' }
// Auto on → dial reflects the auto-chosen count (read-only); off → manual.
if(document.activeElement!==autochk) autochk.checked=!!s.auto
conc.disabled=!!s.auto; conc.style.opacity=s.auto?0.55:1
conchint.textContent=(s.auto?('auto-tuning downloaders to keep the GPU fed · max '+CAP):('manual downloaders · max '+CAP))
+(s.idle?' · idle — queue empty, lease poll backed off (new work noticed within ~15 min)'
:(s.bw_capped?' · holding at the bandwidth cap (more downloaders would not go faster)':''))
if(document.activeElement!==conc) conc.value=s.concurrency
conc.max=CAP
// Connection pill + queue come only from the /status poll (the Start/Stop POST
// responses skip the slow curator call to stay snappy) — guard so an action
// response doesn't blank them.
if('configured' in s){
const ok=s.configured
fc.textContent=s.fc_url; cfg.textContent=ok?'set':'MISSING'
// Pill colour + label track the real state: green only when running AND
// curator is answering; amber for the transient states + a running-but-
// unreachable curator; grey when stopped; red with no token.
let dc='dot', lbl='stopped'
if(!ok){ dc='dot red'; lbl='no token' }
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable' }
else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
dot.className=dc; connlbl.textContent=lbl
banner.style.display=(st==='running' && !s.queue)?'block':'none'
queue.textContent=s.queue?('queue · pending '+s.queue.pending+' · in flight '+s.queue.leased+' · done '+s.queue.done+' · errored '+s.queue.error):'queue · unreachable'
}
}
// GPU meters poll their OWN endpoint on a fast cadence — kept off /status so a
// slow curator queue call can't freeze the bars (they only stale on refresh).
let UAVG=null // smoothed util for the bar (raw util swings 0↔99; show the trend)
async function refreshGpu(){
let g; try{ g=await (await fetch('/gpu')).json() }catch{ return }
if(g && g.util_pct!=null){
// Prefer the agent's own EWMA (util_smooth) when running; otherwise smooth
// the raw reading here so a stopped agent's bar still glides, not jumps.
const raw=g.util_pct
UAVG = (g.util_smooth!=null) ? g.util_smooth
: (UAVG==null ? raw : 0.25*raw + 0.75*UAVG)
const used=g.mem_used_mb, tot=g.mem_total_mb||1
utillbl.textContent=Math.round(UAVG)+'% · '+g.temp_c+'°C'; utilbar.style.width=Math.round(UAVG)+'%'
vramlbl.textContent=used+' / '+tot+' MB'; gpubar.style.width=Math.round(100*used/tot)+'%'
} else { UAVG=null; utillbl.textContent='n/a'; vramlbl.textContent='n/a (CPU?)'; utilbar.style.width='0%'; gpubar.style.width='0%' }
}
async function refreshLogs(){
try{
const r=await (await fetch('/logs')).json()
const el=logs, atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<40
el.textContent=(r.lines&&r.lines.length)?r.lines.join('\\n'):'waiting for activity…'
if(atBottom) el.scrollTop=el.scrollHeight
}catch{}
}
async function copyLogs(){
const txt=logs.textContent||''
try{ await navigator.clipboard.writeText(txt) }
catch{ const t=document.createElement('textarea'); t.value=txt; document.body.appendChild(t);
t.select(); try{document.execCommand('copy')}catch{}; t.remove() }
copybtn.textContent='Copied'; setTimeout(()=>{copybtn.textContent='Copy'},1200)
}
refresh(); refreshGpu(); refreshLogs()
setInterval(refresh,3000); setInterval(refreshGpu,1500); setInterval(refreshLogs,2500)
</script></body></html>"""