feat: sleep duration timer in stats panel
Track sleeping_since (ISO timestamp) in ServerState; auto-cleared by _set() whenever state leaves sleeping. Frontend runs a 1s interval showing elapsed time as "Xs", "Xm Ys", or "Xh Ym" in a new ASLEEP stat row (visible only during state-sleeping). Timer survives page reload via server-side timestamp. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,9 @@
|
|||||||
const svPrompt = document.getElementById('sv-prompt');
|
const svPrompt = document.getElementById('sv-prompt');
|
||||||
const svSpeed = document.getElementById('sv-speed');
|
const svSpeed = document.getElementById('sv-speed');
|
||||||
const svTokens = document.getElementById('sv-tokens');
|
const svTokens = document.getElementById('sv-tokens');
|
||||||
const svLoadStage = document.getElementById('sv-load-stage');
|
const svLoadStage = document.getElementById('sv-load-stage');
|
||||||
const svReqs = document.getElementById('sv-reqs');
|
const svSleepTimer = document.getElementById('sv-sleep-timer');
|
||||||
|
const svReqs = document.getElementById('sv-reqs');
|
||||||
|
|
||||||
// ── State class management ────────────────────────────────────────────────
|
// ── State class management ────────────────────────────────────────────────
|
||||||
const ALL_STATES = [
|
const ALL_STATES = [
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function update(msg) {
|
function update(msg) {
|
||||||
const { state, model, loading, metrics, request_count } = msg;
|
const { state, model, loading, metrics, request_count, sleeping_since } = msg;
|
||||||
|
|
||||||
applyState(state);
|
applyState(state);
|
||||||
|
|
||||||
@@ -92,6 +93,13 @@
|
|||||||
svPrompt.textContent = svSpeed.textContent = svTokens.textContent = '—';
|
svPrompt.textContent = svSpeed.textContent = svTokens.textContent = '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sleep timer
|
||||||
|
if (state === 'sleeping' && sleeping_since) {
|
||||||
|
startSleepTimer(sleeping_since);
|
||||||
|
} else {
|
||||||
|
stopSleepTimer();
|
||||||
|
}
|
||||||
|
|
||||||
// Pulse dot on message
|
// Pulse dot on message
|
||||||
wsDot.classList.remove('pulse');
|
wsDot.classList.remove('pulse');
|
||||||
void wsDot.offsetWidth; // force reflow to restart animation
|
void wsDot.offsetWidth; // force reflow to restart animation
|
||||||
@@ -130,6 +138,40 @@
|
|||||||
document.addEventListener('touchmove', e => { if (dragging) { e.preventDefault(); onDrag(e.touches[0].clientX); } }, { passive: false });
|
document.addEventListener('touchmove', e => { if (dragging) { e.preventDefault(); onDrag(e.touches[0].clientX); } }, { passive: false });
|
||||||
document.addEventListener('touchend', endDrag);
|
document.addEventListener('touchend', endDrag);
|
||||||
|
|
||||||
|
// ── Sleep timer ───────────────────────────────────────────────────────────
|
||||||
|
let sleepingSince = null;
|
||||||
|
let sleepInterval = null;
|
||||||
|
|
||||||
|
function formatDuration(sec) {
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (h > 0) return h + 'h ' + m + 'm';
|
||||||
|
if (m > 0) return m + 'm ' + s + 's';
|
||||||
|
return s + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickSleepTimer() {
|
||||||
|
if (!sleepingSince) return;
|
||||||
|
const elapsed = Math.floor((Date.now() - new Date(sleepingSince).getTime()) / 1000);
|
||||||
|
svSleepTimer.textContent = formatDuration(elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSleepTimer(since) {
|
||||||
|
if (sleepingSince === since) return;
|
||||||
|
sleepingSince = since;
|
||||||
|
clearInterval(sleepInterval);
|
||||||
|
tickSleepTimer();
|
||||||
|
sleepInterval = setInterval(tickSleepTimer, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSleepTimer() {
|
||||||
|
sleepingSince = null;
|
||||||
|
clearInterval(sleepInterval);
|
||||||
|
sleepInterval = null;
|
||||||
|
svSleepTimer.textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
// ── HTTP polling fallback ─────────────────────────────────────────────────
|
// ── HTTP polling fallback ─────────────────────────────────────────────────
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
|||||||
@@ -147,6 +147,10 @@
|
|||||||
<span class="sk">STAGE</span>
|
<span class="sk">STAGE</span>
|
||||||
<span class="sv" id="sv-load-stage">—</span>
|
<span class="sv" id="sv-load-stage">—</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-row" id="row-sleep-timer">
|
||||||
|
<span class="sk">ASLEEP</span>
|
||||||
|
<span class="sv" id="sv-sleep-timer">—</span>
|
||||||
|
</div>
|
||||||
<div class="stat-row" id="row-reqs">
|
<div class="stat-row" id="row-reqs">
|
||||||
<span class="sk">REQS</span>
|
<span class="sk">REQS</span>
|
||||||
<span class="sv" id="sv-reqs">0</span>
|
<span class="sv" id="sv-reqs">0</span>
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ body.state-sleeping #row-speed,
|
|||||||
body.state-sleeping #row-tokens,
|
body.state-sleeping #row-tokens,
|
||||||
body.state-sleeping #row-load-stage { display: none; }
|
body.state-sleeping #row-load-stage { display: none; }
|
||||||
|
|
||||||
|
body:not(.state-sleeping) #row-sleep-timer { display: none; }
|
||||||
|
|
||||||
body.state-waiting #row-prompt,
|
body.state-waiting #row-prompt,
|
||||||
body.state-waiting #row-speed,
|
body.state-waiting #row-speed,
|
||||||
body.state-waiting #row-tokens,
|
body.state-waiting #row-tokens,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class ServerState:
|
|||||||
loading: Optional[LoadingInfo] = None
|
loading: Optional[LoadingInfo] = None
|
||||||
metrics: Metrics = field(default_factory=Metrics)
|
metrics: Metrics = field(default_factory=Metrics)
|
||||||
request_count: int = 0
|
request_count: int = 0
|
||||||
|
sleeping_since: Optional[str] = None # ISO timestamp when sleeping started
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -59,6 +60,7 @@ class ServerState:
|
|||||||
"loading": asdict(self.loading) if self.loading else None,
|
"loading": asdict(self.loading) if self.loading else None,
|
||||||
"metrics": asdict(self.metrics),
|
"metrics": asdict(self.metrics),
|
||||||
"request_count": self.request_count,
|
"request_count": self.request_count,
|
||||||
|
"sleeping_since": self.sleeping_since,
|
||||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,12 +217,17 @@ class LogParser:
|
|||||||
self._state.model = model_id
|
self._state.model = model_id
|
||||||
return self._set(state="idle", loading=None)
|
return self._set(state="idle", loading=None)
|
||||||
if s == "sleeping":
|
if s == "sleeping":
|
||||||
return self._set(state="sleeping")
|
return self._set(
|
||||||
|
state="sleeping",
|
||||||
|
sleeping_since=datetime.now().isoformat(timespec="seconds"),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _set(self, **kwargs) -> ServerState:
|
def _set(self, **kwargs) -> ServerState:
|
||||||
|
if "state" in kwargs and kwargs["state"] != "sleeping":
|
||||||
|
self._state.sleeping_since = None
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
setattr(self._state, k, v)
|
setattr(self._state, k, v)
|
||||||
return self._state
|
return self._state
|
||||||
|
|||||||
Reference in New Issue
Block a user