diff --git a/frontend/app.js b/frontend/app.js index 8d9b86e..a8762cb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -6,7 +6,7 @@ const wsDot = document.getElementById('ws-dot'); const statsPanel = document.getElementById('stats-panel'); const resizeHandle = document.getElementById('resize-handle'); - const stateLabel = document.getElementById('state-label'); + const stateLabelEl = document.getElementById('state-label'); const progressFill = document.getElementById('progress-fill'); const progressLabel = document.getElementById('progress-label'); const svModel = document.getElementById('sv-model'); @@ -23,6 +23,15 @@ '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')); @@ -42,8 +51,8 @@ applyState(state); - stateLabel.textContent = state.replace('_', ' ').toUpperCase(); - svState.textContent = state.replace('_', ' '); + stateLabelEl.textContent = stateLabel(state); + svState.textContent = stateLabel(state).toLowerCase(); svModel.textContent = model || '—'; svReqs.textContent = request_count ?? 0; @@ -111,16 +120,43 @@ document.addEventListener('touchmove', e => { if (dragging) { e.preventDefault(); onDrag(e.touches[0].clientX); } }, { passive: false }); document.addEventListener('touchend', endDrag); + // ── 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 = 'ws://' + location.host + '/ws'; + const url = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws'; ws = new WebSocket(url); ws.addEventListener('open', () => { + stopPolling(); wsDot.className = 'connected'; retryDelay = 1000; }); @@ -134,9 +170,7 @@ }); ws.addEventListener('close', () => { - wsDot.className = ''; - applyState('offline'); - stateLabel.textContent = 'DISCONNECTED'; + startPolling(); setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 2, MAX_DELAY); }); diff --git a/frontend/style.css b/frontend/style.css index bd9e89a..5258967 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -46,15 +46,15 @@ html, body { #screen { position: relative; width: 280px; - min-height: 380px; + min-height: 280px; background: #0d0d06; border: 3px solid var(--amber-dim); border-radius: 12px; - padding: 1.5rem 1rem 1rem; + padding: 1.2rem 1rem 0.8rem; display: flex; flex-direction: column; align-items: center; - gap: 0.8rem; + gap: 0.5rem; /* CRT scanline overlay */ background-image: repeating-linear-gradient( 0deg, @@ -78,6 +78,7 @@ html, body { transition: background 0.3s; } #ws-dot.connected { background: var(--green); } +#ws-dot.polling { background: var(--amber-dim); } #ws-dot.pulse { animation: pulse-dot 0.3s ease-out; } @keyframes pulse-dot { @@ -88,16 +89,16 @@ html, body { /* ── Llama container ─────────────────────────────────────────────────────── */ #llama-wrap { position: relative; - width: 160px; - height: 200px; + width: 150px; + height: 175px; display: flex; align-items: center; justify-content: center; } #llama { - width: 140px; - height: 180px; + width: 135px; + height: 160px; } /* SVG part colors */ diff --git a/log_parser.py b/log_parser.py index ed757e6..484024a 100644 --- a/log_parser.py +++ b/log_parser.py @@ -85,6 +85,8 @@ _RELEASE_RE = re.compile(r'slot\s+release:.*stop processing') _UNLOAD_RE = re.compile(r'unload_lru:|unload: stopping model instance') _LOAD_ERROR_RE = re.compile(r'exiting due to model loading error') _EXIT_ERROR_RE = re.compile(r'operator\(\):.*exited with status 1') +_CLIENT_CANCEL_RE = re.compile(r'http client error: Connection handling canceled') +_CANCEL_TASK_RE = re.compile(r'stop: cancel task') # ── State machine ───────────────────────────────────────────────────────────── @@ -125,6 +127,8 @@ class LogParser: return self._set(state="unloading") if _EXIT_ERROR_RE.search(content): return self._set(state="error") + if _CLIENT_CANCEL_RE.search(content): + return self._set(state="idle", loading=None, metrics=Metrics()) return None # ── Child-process lines ────────────────────────────────────────────────── @@ -136,6 +140,8 @@ class LogParser: return self._handle_cmd_state(m.group(1)) if _LOAD_ERROR_RE.search(content): return self._set(state="error") + if _CANCEL_TASK_RE.search(content): + return self._set(state="idle", loading=None, metrics=Metrics()) if _WARMUP_RE.search(content): return self._set(state="warming_up") if _IDLE_RE.search(content): diff --git a/server.py b/server.py index 5e825e0..c0570c7 100644 --- a/server.py +++ b/server.py @@ -29,6 +29,10 @@ def create_app(log_path: str, reopen_log: bool = False) -> FastAPI: async def index(): return FileResponse(FRONTEND_DIR / "index.html") + @app.get("/state") + async def get_state(): + return last_state + @app.websocket("/ws") async def ws_endpoint(ws: WebSocket): await ws.accept()