Compare commits
15 Commits
54fba1b400
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b3c945ee | ||
|
|
a5e59df624 | ||
|
|
7bda83998e | ||
|
|
e2996ff0b3 | ||
|
|
ce9b2d73f7 | ||
|
|
8712931c23 | ||
|
|
9dcb5b977a | ||
|
|
311819b51c | ||
|
|
fddbd8f4d4 | ||
|
|
c3b06d4e99 | ||
|
|
7e82eaf3f9 | ||
|
|
5541822936 | ||
|
|
2e757fa645 | ||
|
|
3367eeb80b | ||
|
|
1411ba241f |
121
README.md
Normal file
121
README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Llamagochi
|
||||
|
||||
A browser-based virtual pet llama that mirrors a running `llama-server` instance by tailing its log file. The llama animates differently depending on what the server is doing — loading a model, processing a prompt, generating tokens, sleeping, or erroring out.
|
||||
|
||||
```
|
||||
IDLE PROCESSING PROMPT GENERATING SLEEPING
|
||||
ear twitches eyes scan left/right mouth talks zzz floats up
|
||||
progress bar shown tail wags
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- `llama-server` running in **router mode** (the multi-model router that logs `cmd_child_to_router:state:{...}` JSON lines)
|
||||
- The log file must be accessible from the machine running Llamagochi (local path or a mount)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd llamagochi
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
python server.py --log /path/to/llama-server.log
|
||||
```
|
||||
|
||||
The web UI is then available at `http://localhost:8080`.
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--log PATH` | — | Path to the llama-server log file (required unless set in `config.json` or `$LLAMAGOCHI_LOG`) |
|
||||
| `--port PORT` | `8080` | HTTP port to listen on |
|
||||
| `--reopen-log` | off | Close and reopen the log file on every poll cycle. Use this when the log is on a network filesystem (sshfs, NFS) that caches file content at open time |
|
||||
|
||||
### Configuration file
|
||||
|
||||
Instead of passing `--log` every time, create `config.json` in the project directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"log": "/path/to/llama-server.log",
|
||||
"reopen_log": true
|
||||
}
|
||||
```
|
||||
|
||||
`reopen_log` in the config is equivalent to `--reopen-log` on the command line. CLI flags take precedence.
|
||||
|
||||
### Environment variable
|
||||
|
||||
```bash
|
||||
export LLAMAGOCHI_LOG=/path/to/llama-server.log
|
||||
python server.py
|
||||
```
|
||||
|
||||
## Reverse proxy (HTTPS)
|
||||
|
||||
Llamagochi uses WebSocket (`/ws`) and HTTP polling (`/state`). Both must be proxied. Example nginx snippet:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
```
|
||||
|
||||
The frontend automatically uses `wss://` when the page is served over HTTPS.
|
||||
|
||||
## States
|
||||
|
||||
| State | What it means |
|
||||
|---|---|
|
||||
| `idle` | Server is up, no active request |
|
||||
| `waiting` | Router received a request, slot not yet assigned |
|
||||
| `loading` | Model is loading into VRAM (progress bar shown) |
|
||||
| `warming_up` | Model weights loaded, running warmup pass |
|
||||
| `processing_prompt` | Evaluating the prompt (progress bar 0–100%) |
|
||||
| `generating` | Generating tokens |
|
||||
| `sleeping` | Server idle-timeout reached, model evicted |
|
||||
| `unloading` | LRU eviction in progress |
|
||||
| `error` | Model failed to load |
|
||||
| `offline` / `LOG OFFLINE` | Llamagochi cannot read the log file |
|
||||
|
||||
## Connection status dot (top-right of screen)
|
||||
|
||||
| Colour | Meaning |
|
||||
|---|---|
|
||||
| Green | WebSocket live — real-time updates |
|
||||
| Amber | WebSocket down, falling back to HTTP polling every 2 s |
|
||||
| Red | Both WebSocket and HTTP polling are unreachable |
|
||||
|
||||
When the WebSocket reconnects, polling stops automatically.
|
||||
|
||||
## Log file format
|
||||
|
||||
Llamagochi expects the log produced by `llama-server` router mode, optionally prefixed with timestamps by the `ts` utility:
|
||||
|
||||
```
|
||||
[2026-06-22 20:09:31] [51201] 3.13.636.722 I srv update_slots: all slots are idle
|
||||
[2026-06-22 20:09:31] 339.42.030.150 I srv proxy_reques: proxying request to model ...
|
||||
```
|
||||
|
||||
Lines prefixed with `[PORT]` are child-process lines; unprefixed lines are router-level. Both are understood.
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
32 tests covering the log parser state machine and log watcher.
|
||||
236
frontend/app.js
Normal file
236
frontend/app.js
Normal file
@@ -0,0 +1,236 @@
|
||||
(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();
|
||||
})();
|
||||
19
frontend/favicon.svg
Normal file
19
frontend/favicon.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="22 2 56 48">
|
||||
<rect x="22" y="2" width="56" height="48" rx="10" fill="#0a0a04"/>
|
||||
<!-- Ears -->
|
||||
<ellipse cx="38" cy="15" rx="5" ry="9" transform="rotate(-15 38 15)" fill="#ffb000"/>
|
||||
<ellipse cx="62" cy="15" rx="5" ry="9" transform="rotate(15 62 15)" fill="#ffb000"/>
|
||||
<!-- Head -->
|
||||
<ellipse cx="50" cy="27" rx="16" ry="14" fill="#ffb000"/>
|
||||
<!-- Nose -->
|
||||
<ellipse cx="50" cy="34" rx="5.5" ry="3.5" fill="#ffb000"/>
|
||||
<!-- Nostrils -->
|
||||
<circle cx="47.5" cy="34" r="1.2" fill="#0a0a04"/>
|
||||
<circle cx="52.5" cy="34" r="1.2" fill="#0a0a04"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="43" cy="25" r="2.8" fill="#0a0a04"/>
|
||||
<circle cx="57" cy="25" r="2.8" fill="#0a0a04"/>
|
||||
<!-- Eye shines -->
|
||||
<circle cx="44.2" cy="23.8" r="0.9" fill="#ffb000"/>
|
||||
<circle cx="58.2" cy="23.8" r="0.9" fill="#ffb000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 859 B |
164
frontend/index.html
Normal file
164
frontend/index.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Llamagochi</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="state-offline">
|
||||
|
||||
<div id="app">
|
||||
|
||||
<!-- ── Screen panel ───────────────────────────────────────────── -->
|
||||
<div id="screen-panel">
|
||||
<div id="screen">
|
||||
|
||||
<div id="ws-dot" title="WebSocket connection"></div>
|
||||
|
||||
<div id="llama-wrap">
|
||||
<svg id="llama" viewBox="0 0 100 150" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<!-- Tail -->
|
||||
<path class="part tail" d="M 72 82 Q 88 68 80 88" stroke-width="5"
|
||||
fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Body -->
|
||||
<rect class="part body" x="28" y="62" width="46" height="42" rx="13"/>
|
||||
|
||||
<!-- Neck -->
|
||||
<rect class="part neck" x="42" y="33" width="16" height="35" rx="7"/>
|
||||
|
||||
<!-- Head -->
|
||||
<ellipse class="part head" cx="50" cy="27" rx="16" ry="14"/>
|
||||
|
||||
<!-- Ears -->
|
||||
<ellipse class="part ear left-ear" cx="38" cy="15" rx="5" ry="9"
|
||||
transform="rotate(-15 38 15)"/>
|
||||
<ellipse class="part ear right-ear" cx="62" cy="15" rx="5" ry="9"
|
||||
transform="rotate(15 62 15)"/>
|
||||
|
||||
<!-- Eyes: open (default) -->
|
||||
<circle class="part eye left-eye" cx="43" cy="25" r="2.8"/>
|
||||
<circle class="part eye right-eye" cx="57" cy="25" r="2.8"/>
|
||||
<circle class="shine" cx="44.2" cy="23.8" r="0.9"/>
|
||||
<circle class="shine" cx="58.2" cy="23.8" r="0.9"/>
|
||||
|
||||
<!-- Eyes: closed (sleeping) -->
|
||||
<path class="part sleep-eye left-sleep-eye"
|
||||
d="M 40 25 Q 43 23 46 25" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path class="part sleep-eye right-sleep-eye"
|
||||
d="M 54 25 Q 57 23 60 25" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Nose -->
|
||||
<ellipse class="part nose" cx="50" cy="34" rx="5.5" ry="3.5"/>
|
||||
<circle cx="47.5" cy="34" r="1.2" class="nostril"/>
|
||||
<circle cx="52.5" cy="34" r="1.2" class="nostril"/>
|
||||
|
||||
<!-- Mouth: closed smile -->
|
||||
<path class="part mouth-closed"
|
||||
d="M 47 38 Q 50 41 53 38" stroke-width="1.8" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Mouth: open (talking) -->
|
||||
<ellipse class="part mouth-open" cx="50" cy="39" rx="4.5" ry="3"/>
|
||||
|
||||
<!-- Legs + hooves (grouped so animation moves both together) -->
|
||||
<g class="leg-fl">
|
||||
<rect class="part leg" x="29" y="100" width="10" height="30" rx="4"/>
|
||||
<rect class="part hoof" x="29" y="126" width="10" height="4" rx="2"/>
|
||||
</g>
|
||||
<g class="leg-fr">
|
||||
<rect class="part leg" x="40" y="100" width="10" height="30" rx="4"/>
|
||||
<rect class="part hoof" x="40" y="126" width="10" height="4" rx="2"/>
|
||||
</g>
|
||||
<g class="leg-bl">
|
||||
<rect class="part leg" x="51" y="100" width="10" height="30" rx="4"/>
|
||||
<rect class="part hoof" x="51" y="126" width="10" height="4" rx="2"/>
|
||||
</g>
|
||||
<g class="leg-br">
|
||||
<rect class="part leg" x="62" y="100" width="10" height="30" rx="4"/>
|
||||
<rect class="part hoof" x="62" y="126" width="10" height="4" rx="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Book (reading) -->
|
||||
<g class="part book">
|
||||
<rect x="10" y="68" width="22" height="16" rx="2"/>
|
||||
<line x1="21" y1="68" x2="21" y2="84" stroke-width="1"/>
|
||||
<line x1="12" y1="73" x2="20" y2="73" stroke-width="1"/>
|
||||
<line x1="12" y1="77" x2="20" y2="77" stroke-width="1"/>
|
||||
<line x1="22" y1="73" x2="30" y2="73" stroke-width="1"/>
|
||||
<line x1="22" y1="77" x2="30" y2="77" stroke-width="1"/>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
||||
<!-- ZZZ bubble (sleeping) -->
|
||||
<div id="zzz-wrap" aria-hidden="true">
|
||||
<span class="z z1">Z</span>
|
||||
<span class="z z2">Z</span>
|
||||
<span class="z z3">Z</span>
|
||||
</div>
|
||||
|
||||
<!-- Speech bubble (generating) -->
|
||||
<div id="speech-bubble" aria-hidden="true">
|
||||
<span class="dot d1">.</span><span class="dot d2">.</span><span class="dot d3">.</span>
|
||||
</div>
|
||||
|
||||
</div><!-- /llama-wrap -->
|
||||
|
||||
<div id="state-label">OFFLINE</div>
|
||||
|
||||
<!-- Loading progress bar (loading state only) -->
|
||||
<div id="progress-wrap">
|
||||
<div id="progress-bar"><div id="progress-fill"></div></div>
|
||||
<div id="progress-label"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /screen -->
|
||||
</div><!-- /screen-panel -->
|
||||
|
||||
<!-- ── Stats panel ────────────────────────────────────────────── -->
|
||||
<div id="stats-panel">
|
||||
<div id="resize-handle" title="Drag to resize"></div>
|
||||
<div class="stat-row" id="row-model">
|
||||
<span class="sk">MODEL</span>
|
||||
<span class="sv" id="sv-model">—</span>
|
||||
</div>
|
||||
<div class="stat-row" id="row-state">
|
||||
<span class="sk">STATE</span>
|
||||
<span class="sv" id="sv-state">offline</span>
|
||||
</div>
|
||||
<div class="stat-row" id="row-prompt">
|
||||
<span class="sk">PROMPT</span>
|
||||
<span class="sv" id="sv-prompt">—</span>
|
||||
</div>
|
||||
<div class="stat-row" id="row-speed">
|
||||
<span class="sk">SPEED</span>
|
||||
<span class="sv" id="sv-speed">—</span>
|
||||
</div>
|
||||
<div class="stat-row" id="row-tokens">
|
||||
<span class="sk">TOKENS</span>
|
||||
<span class="sv" id="sv-tokens">—</span>
|
||||
</div>
|
||||
<div class="stat-row" id="row-load-stage">
|
||||
<span class="sk">STAGE</span>
|
||||
<span class="sv" id="sv-load-stage">—</span>
|
||||
</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">
|
||||
<span class="sk">REQS</span>
|
||||
<span class="sv" id="sv-reqs">0</span>
|
||||
</div>
|
||||
</div><!-- /stats-panel -->
|
||||
|
||||
</div><!-- /app -->
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
477
frontend/style.css
Normal file
477
frontend/style.css
Normal file
@@ -0,0 +1,477 @@
|
||||
/* ── Reset & base ─────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0a04;
|
||||
--amber: #ffb000;
|
||||
--amber-dim: #a07000;
|
||||
--amber-dk: #6a4a00;
|
||||
--green: #00ff88;
|
||||
--red: #ff4444;
|
||||
--font: 'Press Start 2P', monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--amber);
|
||||
font-family: var(--font);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── Two-panel layout ────────────────────────────────────────────────────── */
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Portrait / narrow: stack vertically */
|
||||
@media (orientation: portrait) {
|
||||
#app {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Screen panel ────────────────────────────────────────────────────────── */
|
||||
#screen-panel {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#screen {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
min-height: 280px;
|
||||
background: #0d0d06;
|
||||
border: 3px solid var(--amber-dim);
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem 1rem 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
/* CRT scanline overlay */
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0,0,0,0.15) 2px,
|
||||
rgba(0,0,0,0.15) 4px
|
||||
);
|
||||
box-shadow: 0 0 24px rgba(255,176,0,0.15), inset 0 0 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ── WebSocket status dot ────────────────────────────────────────────────── */
|
||||
#ws-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
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 {
|
||||
0% { transform: scale(1.8); opacity: 0.6; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Llama container ─────────────────────────────────────────────────────── */
|
||||
#llama-wrap {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 175px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#llama {
|
||||
width: 135px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
/* SVG part colors */
|
||||
.part { fill: var(--amber); stroke: none; }
|
||||
.tail { stroke: var(--amber); fill: none; }
|
||||
.hoof { fill: var(--amber-dk); }
|
||||
.nostril { fill: var(--bg); }
|
||||
.shine { fill: var(--bg); }
|
||||
.mouth-closed { stroke: var(--bg); fill: none; }
|
||||
.mouth-open { fill: var(--bg); }
|
||||
.book { fill: var(--amber-dim); stroke: var(--amber-dk); stroke-width: 0.5; }
|
||||
.book line { stroke: var(--amber); }
|
||||
.sleep-eye { stroke: var(--bg); fill: none; }
|
||||
|
||||
/* ── State-based element visibility ─────────────────────────────────────── */
|
||||
|
||||
/* Default: open eyes, no accessories */
|
||||
.sleep-eye { display: none; }
|
||||
.mouth-open { display: none; }
|
||||
.book { display: none; }
|
||||
#zzz-wrap { display: none; }
|
||||
#speech-bubble { display: none; }
|
||||
#progress-wrap { display: none; }
|
||||
|
||||
/* sleeping */
|
||||
body.state-sleeping .sleep-eye { display: block; }
|
||||
body.state-sleeping .eye { display: none; }
|
||||
body.state-sleeping .shine { display: none; }
|
||||
body.state-sleeping #zzz-wrap { display: flex; }
|
||||
|
||||
/* processing_prompt */
|
||||
body.state-processing_prompt .book { display: block; }
|
||||
|
||||
/* generating */
|
||||
body.state-generating .mouth-open { display: block; }
|
||||
body.state-generating .mouth-closed { display: none; }
|
||||
body.state-generating #speech-bubble { display: flex; }
|
||||
|
||||
/* loading */
|
||||
body.state-loading #progress-wrap { display: flex; }
|
||||
|
||||
/* processing_prompt */
|
||||
body.state-processing_prompt #progress-wrap { display: flex; }
|
||||
|
||||
/* offline */
|
||||
body.state-offline #llama { opacity: 0.15; }
|
||||
|
||||
/* error: llama flashes red */
|
||||
body.state-error { --amber: var(--red); --amber-dim: #cc3333; --amber-dk: #882222; }
|
||||
body.state-error #llama { animation: error-flash 1s ease-in-out infinite; }
|
||||
@keyframes error-flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── ZZZ ─────────────────────────────────────────────────────────────────── */
|
||||
#zzz-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.z {
|
||||
font-family: var(--font);
|
||||
color: var(--amber);
|
||||
animation: float-z 2.4s ease-in-out infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
.z1 { font-size: 9px; animation-delay: 0s; }
|
||||
.z2 { font-size: 11px; animation-delay: 0.6s; }
|
||||
.z3 { font-size: 13px; animation-delay: 1.2s; }
|
||||
|
||||
@keyframes float-z {
|
||||
0% { opacity: 0; transform: translateY(0); }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 0.8; }
|
||||
100% { opacity: 0; transform: translateY(-28px); }
|
||||
}
|
||||
|
||||
/* ── Speech bubble ───────────────────────────────────────────────────────── */
|
||||
#speech-bubble {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
background: var(--amber);
|
||||
color: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 4px 7px;
|
||||
font-size: 14px;
|
||||
gap: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
#speech-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -7px;
|
||||
left: 10px;
|
||||
border: 7px solid transparent;
|
||||
border-top-color: var(--amber);
|
||||
border-bottom: 0;
|
||||
}
|
||||
.dot {
|
||||
animation: blink-dot 1.2s ease-in-out infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
.d1 { animation-delay: 0s; }
|
||||
.d2 { animation-delay: 0.3s; }
|
||||
.d3 { animation-delay: 0.6s; }
|
||||
|
||||
@keyframes blink-dot {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── State label ─────────────────────────────────────────────────────────── */
|
||||
#state-label {
|
||||
font-size: 8px;
|
||||
color: var(--amber-dim);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
#progress-wrap {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
#progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border: 1px solid var(--amber-dim);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#progress-fill {
|
||||
height: 100%;
|
||||
background: var(--amber);
|
||||
width: 0%;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
#progress-label {
|
||||
font-size: 8px;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── Resize handle ───────────────────────────────────────────────────────── */
|
||||
#resize-handle {
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.15s;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
}
|
||||
#resize-handle:hover,
|
||||
#resize-handle.dragging { opacity: 1; }
|
||||
#resize-handle::after {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 48px;
|
||||
background: var(--amber-dim);
|
||||
border-radius: 1px;
|
||||
box-shadow: -4px 0 0 var(--amber-dim), 4px 0 0 var(--amber-dim);
|
||||
}
|
||||
@media (orientation: portrait) {
|
||||
#resize-handle { display: none; }
|
||||
}
|
||||
|
||||
/* ── Stats panel ─────────────────────────────────────────────────────────── */
|
||||
#stats-panel {
|
||||
position: relative;
|
||||
flex: 0 0 300px;
|
||||
background: #0d0d06;
|
||||
border: 3px solid var(--amber-dim);
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
box-shadow: 0 0 24px rgba(255,176,0,0.10);
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
#stats-panel { width: 280px; flex: 0 0 auto; }
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.sk {
|
||||
font-size: 7px;
|
||||
color: var(--amber-dk);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.sv {
|
||||
font-size: 8px;
|
||||
color: var(--amber);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* hide irrelevant rows per state */
|
||||
body.state-offline #row-model,
|
||||
body.state-offline #row-prompt,
|
||||
body.state-offline #row-speed,
|
||||
body.state-offline #row-tokens,
|
||||
body.state-offline #row-load-stage { display: none; }
|
||||
|
||||
body.state-starting #row-prompt,
|
||||
body.state-starting #row-speed,
|
||||
body.state-starting #row-tokens,
|
||||
body.state-starting #row-load-stage { display: none; }
|
||||
|
||||
body.state-loading #row-prompt,
|
||||
body.state-loading #row-speed,
|
||||
body.state-loading #row-tokens { display: none; }
|
||||
|
||||
body.state-idle #row-prompt,
|
||||
body.state-idle #row-speed,
|
||||
body.state-idle #row-tokens,
|
||||
body.state-idle #row-load-stage { display: none; }
|
||||
|
||||
body.state-sleeping #row-prompt,
|
||||
body.state-sleeping #row-speed,
|
||||
body.state-sleeping #row-tokens,
|
||||
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-speed,
|
||||
body.state-waiting #row-tokens,
|
||||
body.state-waiting #row-load-stage { display: none; }
|
||||
|
||||
body.state-unloading #row-prompt,
|
||||
body.state-unloading #row-speed,
|
||||
body.state-unloading #row-tokens,
|
||||
body.state-unloading #row-load-stage { display: none; }
|
||||
|
||||
body.state-error #row-prompt,
|
||||
body.state-error #row-speed,
|
||||
body.state-error #row-tokens,
|
||||
body.state-error #row-load-stage { display: none; }
|
||||
|
||||
body.state-processing_prompt #row-speed,
|
||||
body.state-processing_prompt #row-load-stage { display: none; }
|
||||
|
||||
body.state-generating #row-load-stage { display: none; }
|
||||
|
||||
/* ── Per-state SVG animations ────────────────────────────────────────────── */
|
||||
|
||||
/* sleeping: gentle breathe on body */
|
||||
body.state-sleeping .body,
|
||||
body.state-sleeping .neck,
|
||||
body.state-sleeping .head,
|
||||
body.state-sleeping .ear {
|
||||
animation: breathe 3.5s ease-in-out infinite;
|
||||
transform-origin: 50px 90px;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(1.03); }
|
||||
}
|
||||
|
||||
/* idle: occasional ear twitch */
|
||||
body.state-idle .right-ear {
|
||||
animation: ear-twitch 5s ease-in-out infinite;
|
||||
transform-origin: 62px 22px;
|
||||
}
|
||||
|
||||
@keyframes ear-twitch {
|
||||
0%, 85%, 100% { transform: rotate(15deg); }
|
||||
90% { transform: rotate(5deg); }
|
||||
95% { transform: rotate(22deg); }
|
||||
}
|
||||
|
||||
/* waiting: both ears perk forward */
|
||||
body.state-waiting .left-ear {
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: 38px 22px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
body.state-waiting .right-ear {
|
||||
transform: rotate(5deg);
|
||||
transform-origin: 62px 22px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
/* loading: walk cycle on legs */
|
||||
body.state-loading .leg-fl,
|
||||
body.state-loading .leg-bl {
|
||||
animation: walk-a 0.7s ease-in-out infinite;
|
||||
transform-origin: 34px 100px;
|
||||
}
|
||||
body.state-loading .leg-fr,
|
||||
body.state-loading .leg-br {
|
||||
animation: walk-b 0.7s ease-in-out infinite;
|
||||
transform-origin: 45px 100px;
|
||||
}
|
||||
|
||||
@keyframes walk-a {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
@keyframes walk-b {
|
||||
0%, 100% { transform: translateY(-6px); }
|
||||
50% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* processing_prompt: eyes move side to side (reading) */
|
||||
body.state-processing_prompt .left-eye,
|
||||
body.state-processing_prompt .right-eye,
|
||||
body.state-processing_prompt .shine {
|
||||
animation: read-eyes 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes read-eyes {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
30% { transform: translateX(-1.5px); }
|
||||
70% { transform: translateX(1.5px); }
|
||||
}
|
||||
|
||||
/* generating: tail wag + mouth animation */
|
||||
body.state-generating .tail {
|
||||
animation: wag 0.45s ease-in-out infinite alternate;
|
||||
transform-origin: 72px 82px;
|
||||
}
|
||||
body.state-generating .mouth-open {
|
||||
animation: talk 0.3s ease-in-out infinite alternate;
|
||||
transform-origin: 50px 39px;
|
||||
}
|
||||
|
||||
@keyframes wag {
|
||||
0% { transform: rotate(-18deg); }
|
||||
100% { transform: rotate(18deg); }
|
||||
}
|
||||
@keyframes talk {
|
||||
0% { transform: scaleY(0.4); }
|
||||
100% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* warming_up: yawn — jaw drops */
|
||||
body.state-warming_up .mouth-open { display: block; }
|
||||
body.state-warming_up .mouth-closed { display: none; }
|
||||
body.state-warming_up .mouth-open {
|
||||
animation: yawn 2.5s ease-in-out infinite;
|
||||
transform-origin: 50px 39px;
|
||||
}
|
||||
@keyframes yawn {
|
||||
0%, 20%, 100% { transform: scaleY(0.3); }
|
||||
50%, 70% { transform: scaleY(1.4); }
|
||||
}
|
||||
|
||||
/* unloading: whole llama fades and drifts right */
|
||||
body.state-unloading #llama {
|
||||
animation: unload 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes unload {
|
||||
0% { transform: translateX(0); opacity: 1; }
|
||||
100% { transform: translateX(12px); opacity: 0.4; }
|
||||
}
|
||||
179
log_parser.py
179
log_parser.py
@@ -37,10 +37,11 @@ class LoadingInfo:
|
||||
|
||||
@dataclass
|
||||
class Metrics:
|
||||
prompt_speed: Optional[float] = None # tokens/sec during prompt eval
|
||||
prompt_tokens: Optional[int] = None # total prompt tokens
|
||||
gen_speed: Optional[float] = None # tokens/sec during generation
|
||||
n_decoded: Optional[int] = None # tokens decoded so far
|
||||
prompt_speed: Optional[float] = None # tokens/sec during prompt eval
|
||||
prompt_tokens: Optional[int] = None # total prompt tokens
|
||||
prompt_progress: Optional[float] = None # 0.0–1.0 from live prompt timing lines
|
||||
gen_speed: Optional[float] = None # tokens/sec during generation
|
||||
n_decoded: Optional[int] = None # tokens decoded so far
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -50,6 +51,7 @@ class ServerState:
|
||||
loading: Optional[LoadingInfo] = None
|
||||
metrics: Metrics = field(default_factory=Metrics)
|
||||
request_count: int = 0
|
||||
sleeping_since: Optional[str] = None # ISO timestamp when sleeping started
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -58,5 +60,174 @@ class ServerState:
|
||||
"loading": asdict(self.loading) if self.loading else None,
|
||||
"metrics": asdict(self.metrics),
|
||||
"request_count": self.request_count,
|
||||
"sleeping_since": self.sleeping_since,
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
# ── Log patterns ─────────────────────────────────────────────────────────────
|
||||
|
||||
_SPAWN_RE = re.compile(r'spawning server instance with name=(\S+)')
|
||||
_CMD_STATE_RE = re.compile(r'^cmd_child_to_router:state:(.+)$')
|
||||
_ROUTER_LISTENING_RE = re.compile(r'llama_server: router server is listening')
|
||||
_WARMUP_RE = re.compile(r'common_init_from_params: warming up')
|
||||
_IDLE_RE = re.compile(r'update_slots: all slots are idle')
|
||||
_PROXY_RE = re.compile(r'proxy_reques: proxying request')
|
||||
_LAUNCH_RE = re.compile(r'slot launch_slot_:.*processing task')
|
||||
_PROMPT_TIMING_RE = re.compile(
|
||||
r'slot print_timing:.*prompt processing, n_tokens =\s+(\d+), progress = ([\d.]+),.*/ ([\d.]+) tokens per second'
|
||||
)
|
||||
_GEN_TIMING_RE = re.compile(
|
||||
r'slot print_timing:.*n_decoded =\s+(\d+), tg =\s+([\d.]+) t/s'
|
||||
)
|
||||
_PROMPT_SUMMARY_RE = re.compile(
|
||||
r'slot print_timing:.*prompt eval time =.*/ \s*(\d+) tokens.*?([\d.]+) tokens per second'
|
||||
)
|
||||
_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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
class LogParser:
|
||||
def __init__(self):
|
||||
self._state = ServerState()
|
||||
self._active_port: Optional[int] = None
|
||||
|
||||
@property
|
||||
def state(self) -> ServerState:
|
||||
return self._state
|
||||
|
||||
def feed(self, raw_line: str) -> Optional[ServerState]:
|
||||
"""
|
||||
Process one raw log line (with or without ts prefix).
|
||||
Returns the updated ServerState if something meaningful changed, else None.
|
||||
"""
|
||||
line = strip_ts(raw_line)
|
||||
if not line:
|
||||
return None
|
||||
kind, port, content = classify_line(line)
|
||||
if kind == "child":
|
||||
return self._handle_child(port, content)
|
||||
return self._handle_router(content)
|
||||
|
||||
# ── Router-level lines ───────────────────────────────────────────────────
|
||||
|
||||
def _handle_router(self, content: str) -> Optional[ServerState]:
|
||||
if _ROUTER_LISTENING_RE.search(content):
|
||||
return self._set(state="starting")
|
||||
if m := _SPAWN_RE.search(content):
|
||||
self._state.model = m.group(1)
|
||||
return None # wait for child's loading signal
|
||||
if _PROXY_RE.search(content):
|
||||
return self._set(state="waiting")
|
||||
if _UNLOAD_RE.search(content):
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
def _handle_child(self, port: int, content: str) -> Optional[ServerState]:
|
||||
self._active_port = port
|
||||
|
||||
if m := _CMD_STATE_RE.match(content):
|
||||
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):
|
||||
return self._set(state="idle", loading=None, metrics=Metrics())
|
||||
if _LAUNCH_RE.search(content):
|
||||
return self._set(state="processing_prompt")
|
||||
if m := _PROMPT_TIMING_RE.search(content):
|
||||
progress = float(m.group(2))
|
||||
done = progress >= 1.0
|
||||
metrics = Metrics(
|
||||
prompt_speed=float(m.group(3)),
|
||||
prompt_tokens=int(m.group(1)),
|
||||
prompt_progress=None if done else progress,
|
||||
)
|
||||
return self._set(state="generating" if done else "processing_prompt", metrics=metrics)
|
||||
if m := _GEN_TIMING_RE.search(content):
|
||||
metrics = Metrics(
|
||||
prompt_speed=self._state.metrics.prompt_speed,
|
||||
prompt_tokens=self._state.metrics.prompt_tokens,
|
||||
gen_speed=float(m.group(2)),
|
||||
n_decoded=int(m.group(1)),
|
||||
)
|
||||
return self._set(state="generating", metrics=metrics)
|
||||
if m := _PROMPT_SUMMARY_RE.search(content):
|
||||
metrics = Metrics(
|
||||
prompt_speed=float(m.group(2)),
|
||||
prompt_tokens=int(m.group(1)),
|
||||
gen_speed=self._state.metrics.gen_speed,
|
||||
n_decoded=self._state.metrics.n_decoded,
|
||||
)
|
||||
if self._state.state == "processing_prompt":
|
||||
# Summary fires right after prompt eval finishes, before first token.
|
||||
# Advance to generating now rather than waiting for the first tg line.
|
||||
# For short prompts the summary arrives after generating has already
|
||||
# started, so the guard prevents stepping back.
|
||||
return self._set(state="generating", metrics=metrics)
|
||||
return self._set(metrics=metrics)
|
||||
if _RELEASE_RE.search(content):
|
||||
return self._set(
|
||||
state="idle",
|
||||
request_count=self._state.request_count + 1,
|
||||
loading=None,
|
||||
metrics=Metrics(),
|
||||
)
|
||||
return None
|
||||
|
||||
# ── cmd_child_to_router:state JSON handler ───────────────────────────────
|
||||
|
||||
def _handle_cmd_state(self, json_str: str) -> Optional[ServerState]:
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
s = data.get("state")
|
||||
payload = data.get("payload")
|
||||
|
||||
if s == "loading" and payload:
|
||||
# Some transition messages only carry {"stage":"name"} with no
|
||||
# progress info — skip them; the next message has the full payload.
|
||||
if not all(k in payload for k in ("stages", "current", "value")):
|
||||
return None
|
||||
loading = LoadingInfo(
|
||||
stages=payload["stages"],
|
||||
current=payload["current"],
|
||||
progress=payload["value"],
|
||||
)
|
||||
return self._set(state="loading", loading=loading)
|
||||
if s == "ready":
|
||||
if payload and (model_id := payload.get("id")):
|
||||
self._state.model = model_id
|
||||
return self._set(state="idle", loading=None)
|
||||
if s == "sleeping":
|
||||
return self._set(
|
||||
state="sleeping",
|
||||
sleeping_since=datetime.now().isoformat(timespec="seconds"),
|
||||
)
|
||||
return None
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _set(self, **kwargs) -> ServerState:
|
||||
if "state" in kwargs and kwargs["state"] != "sleeping":
|
||||
self._state.sleeping_since = None
|
||||
for k, v in kwargs.items():
|
||||
setattr(self._state, k, v)
|
||||
return self._state
|
||||
|
||||
120
log_watcher.py
Normal file
120
log_watcher.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Callable, Awaitable, Optional
|
||||
|
||||
|
||||
class LogWatcher:
|
||||
POLL_INTERVAL = 0.1
|
||||
FILE_RETRY_INTERVAL = 1.0
|
||||
SEED_BYTES = 8192 # read last ~8 KB on open to seed initial state
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
on_line: Callable[[str], Awaitable[None]],
|
||||
on_offline: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
offline_timeout: float = 60.0,
|
||||
reopen: bool = False,
|
||||
):
|
||||
self._path = Path(path)
|
||||
self._on_line = on_line
|
||||
self._on_offline = on_offline
|
||||
self._offline_timeout = offline_timeout
|
||||
self._reopen = reopen
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
||||
def start(self) -> asyncio.Task:
|
||||
self._task = asyncio.create_task(self._run())
|
||||
return self._task
|
||||
|
||||
def stop(self):
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
|
||||
async def _run(self):
|
||||
offline_since: Optional[float] = None
|
||||
|
||||
if self._reopen:
|
||||
await self._run_reopen()
|
||||
else:
|
||||
await self._run_keepopen()
|
||||
|
||||
async def _run_keepopen(self):
|
||||
"""Default: keep file open and poll readline(). Works on local filesystems."""
|
||||
offline_since: Optional[float] = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
with open(self._path, 'r', errors='replace') as f:
|
||||
f.seek(0, 2)
|
||||
end = f.tell()
|
||||
f.seek(max(0, end - self.SEED_BYTES))
|
||||
if f.tell() > 0:
|
||||
f.readline() # discard partial first line
|
||||
offline_since = None
|
||||
while True:
|
||||
line = f.readline()
|
||||
if line:
|
||||
try:
|
||||
await self._on_line(line)
|
||||
except Exception:
|
||||
pass # keep tailing even if a line causes a parser error
|
||||
else:
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
except OSError:
|
||||
now = asyncio.get_event_loop().time()
|
||||
if offline_since is None:
|
||||
offline_since = now
|
||||
elif now - offline_since >= self._offline_timeout and self._on_offline:
|
||||
await self._on_offline()
|
||||
offline_since = None
|
||||
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def _run_reopen(self):
|
||||
"""Close and reopen each poll cycle. Use for network filesystems (e.g. sshfs)
|
||||
that cache file content at open time and don't propagate remote appends to
|
||||
held-open file descriptors."""
|
||||
offline_since: Optional[float] = None
|
||||
pos: int = -1 # -1 = not yet seeded
|
||||
|
||||
while True:
|
||||
try:
|
||||
with open(self._path, 'r', errors='replace') as f:
|
||||
f.seek(0, 2)
|
||||
end = f.tell()
|
||||
|
||||
if pos < 0:
|
||||
f.seek(max(0, end - self.SEED_BYTES))
|
||||
if f.tell() > 0:
|
||||
f.readline() # discard partial first line
|
||||
pos = f.tell()
|
||||
elif end < pos:
|
||||
pos = 0 # file truncated / rotated
|
||||
|
||||
f.seek(pos)
|
||||
while True:
|
||||
line = f.readline()
|
||||
if line:
|
||||
pos = f.tell()
|
||||
try:
|
||||
await self._on_line(line)
|
||||
except Exception:
|
||||
pass # keep tailing even if a line causes a parser error
|
||||
else:
|
||||
break
|
||||
|
||||
offline_since = None
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
except OSError:
|
||||
now = asyncio.get_event_loop().time()
|
||||
if offline_since is None:
|
||||
offline_since = now
|
||||
elif now - offline_since >= self._offline_timeout and self._on_offline:
|
||||
await self._on_offline()
|
||||
offline_since = None
|
||||
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
116
server.py
Normal file
116
server.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from log_parser import LogParser, ServerState
|
||||
from log_watcher import LogWatcher
|
||||
|
||||
FRONTEND_DIR = Path(__file__).parent / "frontend"
|
||||
|
||||
# ── App factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
def create_app(log_path: str, reopen_log: bool = False) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
|
||||
clients: set[WebSocket] = set()
|
||||
parser = LogParser()
|
||||
last_state: dict = parser.state.to_dict()
|
||||
|
||||
@app.get("/")
|
||||
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()
|
||||
clients.add(ws)
|
||||
await ws.send_text(json.dumps(last_state))
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
clients.discard(ws)
|
||||
|
||||
async def broadcast(state: ServerState):
|
||||
nonlocal last_state, clients
|
||||
last_state = state.to_dict()
|
||||
payload = json.dumps(last_state)
|
||||
dead: set[WebSocket] = set()
|
||||
for ws in list(clients):
|
||||
try:
|
||||
await ws.send_text(payload)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
clients -= dead
|
||||
|
||||
async def on_line(line: str):
|
||||
result = parser.feed(line)
|
||||
if result is not None:
|
||||
await broadcast(result)
|
||||
|
||||
async def on_offline():
|
||||
parser._state.state = "offline"
|
||||
await broadcast(parser.state)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
watcher = LogWatcher(log_path, on_line, on_offline=on_offline, reopen=reopen_log)
|
||||
watcher.start()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ── CLI entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
def _config_bool(key: str) -> bool:
|
||||
config_file = Path("config.json")
|
||||
if config_file.exists():
|
||||
return bool(json.loads(config_file.read_text()).get(key, False))
|
||||
return False
|
||||
|
||||
|
||||
def resolve_log_path(cli_log: Optional[str]) -> str:
|
||||
path = cli_log or os.environ.get("LLAMAGOCHI_LOG")
|
||||
if not path:
|
||||
config_file = Path("config.json")
|
||||
if config_file.exists():
|
||||
data = json.loads(config_file.read_text())
|
||||
path = data.get("log")
|
||||
if not path:
|
||||
raise SystemExit(
|
||||
"Error: log file path required.\n"
|
||||
" Use --log /path/to/llama-server.log\n"
|
||||
" or set LLAMAGOCHI_LOG env var\n"
|
||||
" or set \"log\" key in config.json"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Llamagochi — llama-server virtual pet")
|
||||
ap.add_argument("--log", help="Path to llama-server log file")
|
||||
ap.add_argument("--port", type=int, default=8080, help="HTTP port (default: 8080)")
|
||||
ap.add_argument("--reopen-log", action="store_true",
|
||||
help="Close and reopen log file each poll cycle (for sshfs/network filesystems)")
|
||||
args = ap.parse_args()
|
||||
log_path = resolve_log_path(args.log)
|
||||
reopen_log = args.reopen_log or _config_bool("reopen_log")
|
||||
app = create_app(log_path, reopen_log=reopen_log)
|
||||
uvicorn.run(app, host="0.0.0.0", port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -93,3 +93,168 @@ def test_server_state_to_dict_generating():
|
||||
assert d["metrics"]["gen_speed"] == 72.2
|
||||
assert d["metrics"]["n_decoded"] == 218
|
||||
assert d["request_count"] == 5
|
||||
|
||||
|
||||
from log_parser import LogParser
|
||||
|
||||
# Helper: feed a list of raw lines (with ts prefix), return final state name
|
||||
def feed_lines(lines):
|
||||
p = LogParser()
|
||||
last = None
|
||||
for line in lines:
|
||||
result = p.feed(line)
|
||||
if result is not None:
|
||||
last = result
|
||||
return p.state.state, p.state
|
||||
|
||||
|
||||
# ── Router startup ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_router_startup():
|
||||
state, _ = feed_lines([
|
||||
"[2026-06-22 14:29:49] 0.00.076.839 I srv llama_server: router server is listening on http://0.0.0.0:8082"
|
||||
])
|
||||
assert state == "starting"
|
||||
|
||||
|
||||
# ── Model spawn + loading progress ──────────────────────────────────────────
|
||||
|
||||
SPAWN_LINE = "[2026-06-22 14:31:59] 2.10.286.889 I srv load: spawning server instance with name=unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended on port 41803"
|
||||
LOADING_START = '[2026-06-22 14:31:59] [41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model","mmproj_model"],"current":"text_model","value":0.0}}'
|
||||
LOADING_MID = '[2026-06-22 14:32:05] [41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model","mmproj_model"],"current":"text_model","value":0.5}}'
|
||||
LOADING_DONE = '[2026-06-22 14:32:08] [41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model","mmproj_model"],"current":"text_model","value":1.0}}'
|
||||
WARMUP_LINE = "[2026-06-22 14:32:10] [41803] 0.11.107.736 I common_init_from_params: warming up the model with an empty run - please wait ..."
|
||||
READY_LINE = '[2026-06-22 14:32:13] [41803] cmd_child_to_router:state:{"state":"ready","payload":{"id":"unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended","aliases":[],"tags":[],"object":"model","created":1782127933,"owned_by":"llamacpp","meta":{}}}'
|
||||
IDLE_LINE = "[2026-06-22 14:32:13] [41803] 0.13.230.405 I srv update_slots: all slots are idle"
|
||||
|
||||
|
||||
def test_model_loading_progress():
|
||||
p = LogParser()
|
||||
p.feed(SPAWN_LINE)
|
||||
p.feed(LOADING_START)
|
||||
assert p.state.state == "loading"
|
||||
assert p.state.loading.progress == 0.0
|
||||
assert p.state.loading.current == "text_model"
|
||||
|
||||
p.feed(LOADING_MID)
|
||||
assert p.state.loading.progress == 0.5
|
||||
|
||||
p.feed(LOADING_DONE)
|
||||
assert p.state.loading.progress == 1.0
|
||||
|
||||
|
||||
def test_spawn_captures_model_name():
|
||||
p = LogParser()
|
||||
p.feed(SPAWN_LINE)
|
||||
assert p.state.model == "unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended"
|
||||
|
||||
|
||||
def test_warmup_state():
|
||||
state, _ = feed_lines([SPAWN_LINE, LOADING_START, WARMUP_LINE])
|
||||
assert state == "warming_up"
|
||||
|
||||
|
||||
def test_ready_transitions_to_idle():
|
||||
state, s = feed_lines([SPAWN_LINE, LOADING_START, WARMUP_LINE, READY_LINE])
|
||||
assert state == "idle"
|
||||
assert s.model == "unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended"
|
||||
|
||||
|
||||
def test_idle_line():
|
||||
state, _ = feed_lines([SPAWN_LINE, LOADING_START, READY_LINE, IDLE_LINE])
|
||||
assert state == "idle"
|
||||
|
||||
|
||||
# ── Request lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
PROXY_LINE = "[2026-06-22 14:32:13] 2.23.870.146 I srv proxy_reques: proxying request to model unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended on port 41803"
|
||||
LAUNCH_LINE = "[2026-06-22 14:34:20] [41803] 0.30.889.639 I slot launch_slot_: id 0 | task 0 | processing task, is_child = 0"
|
||||
PROMPT_TIMING = "[2026-06-22 14:33:17] [41803] 0.56.093.911 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 276, progress = 0.99, t = 4.90 s / 56.38 tokens per second"
|
||||
GEN_TIMING = "[2026-06-22 14:33:20] [41803] 0.59.444.220 I slot print_timing: id 0 | task 0 | n_decoded = 156, tg = 51.94 t/s, tg_3s = 51.94 t/s"
|
||||
RELEASE_LINE = "[2026-06-22 14:33:24] [41803] 1.02.743.373 I slot release: id 0 | task 0 | stop processing: n_tokens = 606, truncated = 0"
|
||||
|
||||
|
||||
def test_proxy_sets_waiting():
|
||||
state, _ = feed_lines([READY_LINE, IDLE_LINE, PROXY_LINE])
|
||||
assert state == "waiting"
|
||||
|
||||
|
||||
def test_launch_sets_processing_prompt():
|
||||
state, _ = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE])
|
||||
assert state == "processing_prompt"
|
||||
|
||||
|
||||
def test_prompt_timing_updates_metrics():
|
||||
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, PROMPT_TIMING])
|
||||
assert s.state == "processing_prompt"
|
||||
assert abs(s.metrics.prompt_speed - 56.38) < 0.01
|
||||
assert s.metrics.prompt_tokens == 276
|
||||
|
||||
|
||||
def test_gen_timing_sets_generating():
|
||||
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, PROMPT_TIMING, GEN_TIMING])
|
||||
assert s.state == "generating"
|
||||
assert abs(s.metrics.gen_speed - 51.94) < 0.01
|
||||
assert s.metrics.n_decoded == 156
|
||||
|
||||
|
||||
# Short prompts never emit a live "prompt processing" line — only the end-of-slot
|
||||
# summary fires, after generation has already started.
|
||||
PROMPT_SUMMARY = "[2026-06-22 14:34:24] [41983] 0.34.388.097 I slot print_timing: id 0 | task 0 | prompt eval time = 425.83 ms / 11 tokens ( 38.71 ms per token, 25.83 tokens per second)"
|
||||
|
||||
|
||||
def test_prompt_summary_captures_speed_during_generating():
|
||||
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, GEN_TIMING, PROMPT_SUMMARY])
|
||||
assert s.state == "generating" # state name unchanged
|
||||
assert abs(s.metrics.prompt_speed - 25.83) < 0.01
|
||||
assert s.metrics.prompt_tokens == 11
|
||||
assert abs(s.metrics.gen_speed - 51.94) < 0.01 # gen metrics preserved
|
||||
|
||||
|
||||
def test_release_increments_request_count():
|
||||
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, GEN_TIMING, RELEASE_LINE])
|
||||
assert s.state == "idle"
|
||||
assert s.request_count == 1
|
||||
assert s.metrics.gen_speed is None # cleared on release
|
||||
|
||||
|
||||
def test_release_clears_metrics():
|
||||
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, GEN_TIMING, RELEASE_LINE])
|
||||
assert s.metrics.prompt_speed is None
|
||||
assert s.metrics.gen_speed is None
|
||||
assert s.metrics.n_decoded is None
|
||||
|
||||
|
||||
# ── Sleeping ─────────────────────────────────────────────────────────────────
|
||||
|
||||
SLEEPING_LINE = '[2026-06-22 15:23:11] [36417] cmd_child_to_router:state:{"state":"sleeping","payload":null}'
|
||||
|
||||
|
||||
def test_sleeping_state():
|
||||
state, _ = feed_lines([IDLE_LINE, SLEEPING_LINE])
|
||||
assert state == "sleeping"
|
||||
|
||||
|
||||
# ── Unloading ────────────────────────────────────────────────────────────────
|
||||
|
||||
UNLOAD_LINE = "[2026-06-22 14:32:19] 2.30.337.683 I srv unload_lru: models_max limit reached, removing LRU name=foo"
|
||||
|
||||
|
||||
def test_unload_lru():
|
||||
state, _ = feed_lines([IDLE_LINE, UNLOAD_LINE])
|
||||
assert state == "unloading"
|
||||
|
||||
|
||||
# ── Without ts prefix (single-instance mode) ────────────────────────────────
|
||||
|
||||
def test_no_ts_prefix():
|
||||
p = LogParser()
|
||||
p.feed("0.00.075.237 I srv llama_server: router server is listening on http://0.0.0.0:8082")
|
||||
assert p.state.state == "starting"
|
||||
|
||||
|
||||
def test_child_no_ts_prefix():
|
||||
p = LogParser()
|
||||
p.feed('[41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model"],"current":"text_model","value":0.3}}')
|
||||
assert p.state.state == "loading"
|
||||
assert p.state.loading.progress == 0.3
|
||||
|
||||
72
tests/test_log_watcher.py
Normal file
72
tests/test_log_watcher.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from log_watcher import LogWatcher
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_watcher_receives_new_lines():
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
received = []
|
||||
|
||||
async def on_line(line):
|
||||
received.append(line.rstrip('\n'))
|
||||
|
||||
watcher = LogWatcher(path, on_line)
|
||||
watcher.start()
|
||||
await asyncio.sleep(0.05) # let watcher open and seek to end
|
||||
|
||||
with open(path, 'a') as f:
|
||||
f.write("line one\n")
|
||||
f.write("line two\n")
|
||||
|
||||
await asyncio.sleep(0.3) # wait for poll cycles
|
||||
watcher.stop()
|
||||
|
||||
assert received == ["line one", "line two"]
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
async def test_watcher_handles_missing_file_then_appears():
|
||||
path = tempfile.mktemp(suffix='.log')
|
||||
received = []
|
||||
|
||||
async def on_line(line):
|
||||
received.append(line.rstrip('\n'))
|
||||
|
||||
watcher = LogWatcher(path, on_line)
|
||||
watcher.start()
|
||||
await asyncio.sleep(0.15) # file doesn't exist yet
|
||||
|
||||
with open(path, 'w') as f:
|
||||
f.write("hello\n")
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
watcher.stop()
|
||||
|
||||
# File appeared after watcher started — lines written before watcher opens
|
||||
# will be consumed. This test just checks it doesn't crash.
|
||||
assert isinstance(received, list)
|
||||
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
async def test_watcher_calls_on_offline_after_timeout():
|
||||
path = tempfile.mktemp(suffix='.log') # never created
|
||||
offline_called = []
|
||||
|
||||
async def on_offline():
|
||||
offline_called.append(True)
|
||||
|
||||
watcher = LogWatcher(path, lambda _: None, on_offline=on_offline, offline_timeout=0.2)
|
||||
watcher.start()
|
||||
await asyncio.sleep(0.5)
|
||||
watcher.stop()
|
||||
|
||||
assert offline_called, "on_offline should have been called"
|
||||
Reference in New Issue
Block a user