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>
237 lines
8.6 KiB
JavaScript
237 lines
8.6 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
// ── DOM refs ──────────────────────────────────────────────────────────────
|
|
const body = document.body;
|
|
const wsDot = document.getElementById('ws-dot');
|
|
const statsPanel = document.getElementById('stats-panel');
|
|
const resizeHandle = document.getElementById('resize-handle');
|
|
const stateLabelEl = document.getElementById('state-label');
|
|
const progressFill = document.getElementById('progress-fill');
|
|
const progressLabel = document.getElementById('progress-label');
|
|
const svModel = document.getElementById('sv-model');
|
|
const svState = document.getElementById('sv-state');
|
|
const svPrompt = document.getElementById('sv-prompt');
|
|
const svSpeed = document.getElementById('sv-speed');
|
|
const svTokens = document.getElementById('sv-tokens');
|
|
const svLoadStage = document.getElementById('sv-load-stage');
|
|
const svSleepTimer = document.getElementById('sv-sleep-timer');
|
|
const svReqs = document.getElementById('sv-reqs');
|
|
|
|
// ── State class management ────────────────────────────────────────────────
|
|
const ALL_STATES = [
|
|
'offline','starting','loading','warming_up','idle',
|
|
'sleeping','waiting','processing_prompt','generating','unloading','error'
|
|
];
|
|
|
|
const STATE_LABELS = {
|
|
offline: 'LOG OFFLINE', // llamagochi can't read the log file
|
|
error: 'LOAD ERROR',
|
|
};
|
|
|
|
function stateLabel(stateName) {
|
|
return STATE_LABELS[stateName] ?? stateName.replace(/_/g, ' ').toUpperCase();
|
|
}
|
|
|
|
function applyState(stateName) {
|
|
ALL_STATES.forEach(s => body.classList.remove('state-' + s));
|
|
body.classList.add('state-' + (ALL_STATES.includes(stateName) ? stateName : 'offline'));
|
|
}
|
|
|
|
// ── DOM update ────────────────────────────────────────────────────────────
|
|
function fmt(val, unit) {
|
|
return val != null ? val.toFixed(1) + ' ' + unit : '—';
|
|
}
|
|
|
|
function truncate(str, max) {
|
|
return str.length > max ? '…' + str.slice(-(max - 1)) : str;
|
|
}
|
|
|
|
function update(msg) {
|
|
const { state, model, loading, metrics, request_count, sleeping_since } = msg;
|
|
|
|
applyState(state);
|
|
|
|
stateLabelEl.textContent = stateLabel(state);
|
|
svState.textContent = stateLabel(state).toLowerCase();
|
|
svModel.textContent = model || '—';
|
|
svReqs.textContent = request_count ?? 0;
|
|
|
|
// Progress bar: model loading or prompt processing
|
|
if (loading) {
|
|
const stageName = (loading.current ?? '').replace(/_/g, ' ');
|
|
const stageIdx = (loading.stages ?? []).indexOf(loading.current);
|
|
const totalStages = (loading.stages ?? []).length;
|
|
const stagePct = Math.round((loading.progress ?? 0) * 100);
|
|
const overallPct = totalStages > 0
|
|
? Math.round(((stageIdx + (loading.progress ?? 0)) / totalStages) * 100)
|
|
: stagePct;
|
|
progressFill.style.width = overallPct + '%';
|
|
progressLabel.textContent = stageName
|
|
+ (totalStages > 1 ? ' ' + (stageIdx + 1) + '/' + totalStages : '')
|
|
+ ' ' + stagePct + '%';
|
|
svLoadStage.textContent = totalStages > 1
|
|
? stageName + ' (' + (stageIdx + 1) + '/' + totalStages + ')'
|
|
: stageName;
|
|
} else if (state === 'processing_prompt' && metrics && metrics.prompt_progress != null) {
|
|
const pct = Math.round(metrics.prompt_progress * 100);
|
|
progressFill.style.width = pct + '%';
|
|
progressLabel.textContent = 'PROMPT ' + pct + '%';
|
|
svLoadStage.textContent = '—';
|
|
} else {
|
|
progressFill.style.width = '0%';
|
|
progressLabel.textContent = '';
|
|
svLoadStage.textContent = '—';
|
|
}
|
|
|
|
// Metrics
|
|
if (metrics) {
|
|
svPrompt.textContent = fmt(metrics.prompt_speed, 't/s');
|
|
svSpeed.textContent = fmt(metrics.gen_speed, 't/s');
|
|
svTokens.textContent = metrics.n_decoded != null ? metrics.n_decoded + ' tok' : '—';
|
|
} else {
|
|
svPrompt.textContent = svSpeed.textContent = svTokens.textContent = '—';
|
|
}
|
|
|
|
// Sleep timer
|
|
if (state === 'sleeping' && sleeping_since) {
|
|
startSleepTimer(sleeping_since);
|
|
} else {
|
|
stopSleepTimer();
|
|
}
|
|
|
|
// Pulse dot on message
|
|
wsDot.classList.remove('pulse');
|
|
void wsDot.offsetWidth; // force reflow to restart animation
|
|
wsDot.classList.add('pulse');
|
|
}
|
|
|
|
// ── Stats panel resize ───────────────────────────────────────────────────
|
|
const SCREEN_W = 280;
|
|
let dragStartX = 0, dragStartW = 0, dragging = false;
|
|
|
|
function startDrag(x) {
|
|
dragging = true;
|
|
dragStartX = x;
|
|
dragStartW = statsPanel.offsetWidth;
|
|
resizeHandle.classList.add('dragging');
|
|
body.style.cursor = 'ew-resize';
|
|
body.style.userSelect = 'none';
|
|
}
|
|
function onDrag(x) {
|
|
if (!dragging) return;
|
|
const w = Math.min(Math.max(dragStartW + (x - dragStartX), SCREEN_W), SCREEN_W * 3);
|
|
statsPanel.style.flex = '0 0 ' + w + 'px';
|
|
}
|
|
function endDrag() {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
resizeHandle.classList.remove('dragging');
|
|
body.style.cursor = body.style.userSelect = '';
|
|
}
|
|
|
|
resizeHandle.addEventListener('mousedown', e => { e.preventDefault(); startDrag(e.clientX); });
|
|
document.addEventListener('mousemove', e => onDrag(e.clientX));
|
|
document.addEventListener('mouseup', endDrag);
|
|
|
|
resizeHandle.addEventListener('touchstart', e => { e.preventDefault(); startDrag(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);
|
|
|
|
// ── 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 ─────────────────────────────────────────────────
|
|
let pollTimer = null;
|
|
const POLL_MS = 2000;
|
|
|
|
async function pollOnce() {
|
|
try {
|
|
const r = await fetch('/state');
|
|
if (!r.ok) throw new Error(r.status);
|
|
update(await r.json());
|
|
wsDot.className = 'polling';
|
|
} catch {
|
|
wsDot.className = ''; // red: llamagochi itself unreachable
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (pollTimer) return;
|
|
pollOnce();
|
|
pollTimer = setInterval(pollOnce, POLL_MS);
|
|
}
|
|
|
|
function stopPolling() {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
|
|
// ── WebSocket with auto-reconnect ─────────────────────────────────────────
|
|
let ws = null;
|
|
let retryDelay = 1000;
|
|
const MAX_DELAY = 30000;
|
|
|
|
function connect() {
|
|
const url = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws';
|
|
ws = new WebSocket(url);
|
|
|
|
ws.addEventListener('open', () => {
|
|
stopPolling();
|
|
wsDot.className = 'connected';
|
|
retryDelay = 1000;
|
|
});
|
|
|
|
ws.addEventListener('message', (event) => {
|
|
try {
|
|
update(JSON.parse(event.data));
|
|
} catch (e) {
|
|
console.error('llamagochi: bad message', e);
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', () => {
|
|
startPolling();
|
|
setTimeout(connect, retryDelay);
|
|
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
|
|
});
|
|
|
|
ws.addEventListener('error', () => {
|
|
ws.close();
|
|
});
|
|
}
|
|
|
|
connect();
|
|
})();
|