feat: WebSocket client with state animation mapping and auto-reconnect
This commit is contained in:
111
frontend/app.js
Normal file
111
frontend/app.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── DOM refs ──────────────────────────────────────────────────────────────
|
||||||
|
const body = document.body;
|
||||||
|
const wsDot = document.getElementById('ws-dot');
|
||||||
|
const stateLabel = 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 svReqs = document.getElementById('sv-reqs');
|
||||||
|
|
||||||
|
// ── State class management ────────────────────────────────────────────────
|
||||||
|
const ALL_STATES = [
|
||||||
|
'offline','starting','loading','warming_up','idle',
|
||||||
|
'sleeping','waiting','processing_prompt','generating','unloading'
|
||||||
|
];
|
||||||
|
|
||||||
|
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 } = msg;
|
||||||
|
|
||||||
|
applyState(state);
|
||||||
|
|
||||||
|
stateLabel.textContent = state.replace('_', ' ').toUpperCase();
|
||||||
|
svState.textContent = state.replace('_', ' ');
|
||||||
|
svModel.textContent = model ? truncate(model, 22) : '—';
|
||||||
|
svReqs.textContent = request_count ?? 0;
|
||||||
|
|
||||||
|
// Loading progress
|
||||||
|
if (loading) {
|
||||||
|
const pct = Math.round((loading.progress ?? 0) * 100);
|
||||||
|
progressFill.style.width = pct + '%';
|
||||||
|
progressLabel.textContent = loading.current + ' ' + pct + '%';
|
||||||
|
svLoadStage.textContent = loading.current;
|
||||||
|
} 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 = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulse dot on message
|
||||||
|
wsDot.classList.remove('pulse');
|
||||||
|
void wsDot.offsetWidth; // force reflow to restart animation
|
||||||
|
wsDot.classList.add('pulse');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket with auto-reconnect ─────────────────────────────────────────
|
||||||
|
let ws = null;
|
||||||
|
let retryDelay = 1000;
|
||||||
|
const MAX_DELAY = 30000;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const url = 'ws://' + location.host + '/ws';
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
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', () => {
|
||||||
|
wsDot.className = '';
|
||||||
|
applyState('offline');
|
||||||
|
stateLabel.textContent = 'DISCONNECTED';
|
||||||
|
setTimeout(connect, retryDelay);
|
||||||
|
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user