fix: sshfs polling, WebSocket broadcast, prompt progress bar, resizable stats panel

- log_watcher: add --reopen-log mode (close/reopen each poll) for sshfs/network
  filesystems; fix nonlocal clients bug causing UnboundLocalError on first broadcast;
  fix list(clients) copy to prevent RuntimeError on concurrent set mutation
- log_parser: capture progress=X.XX from prompt timing lines into Metrics.prompt_progress
- frontend: show progress bar during processing_prompt state with PROMPT N% label;
  widen stats panel to 300px; remove model name truncation; add drag handle on
  right border of stats panel to resize between 280px–840px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-06-22 21:02:34 +03:00
parent fddbd8f4d4
commit 311819b51c
6 changed files with 157 additions and 17 deletions

View File

@@ -2,8 +2,10 @@
'use strict';
// ── DOM refs ──────────────────────────────────────────────────────────────
const body = document.body;
const wsDot = document.getElementById('ws-dot');
const body = document.body;
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 progressFill = document.getElementById('progress-fill');
const progressLabel = document.getElementById('progress-label');
@@ -42,15 +44,20 @@
stateLabel.textContent = state.replace('_', ' ').toUpperCase();
svState.textContent = state.replace('_', ' ');
svModel.textContent = model ? truncate(model, 22) : '—';
svModel.textContent = model || '—';
svReqs.textContent = request_count ?? 0;
// Loading progress
// Progress bar: model loading or prompt processing
if (loading) {
const pct = Math.round((loading.progress ?? 0) * 100);
progressFill.style.width = pct + '%';
progressLabel.textContent = loading.current + ' ' + pct + '%';
svLoadStage.textContent = loading.current;
} 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 = '';
@@ -72,6 +79,38 @@
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);
// ── WebSocket with auto-reconnect ─────────────────────────────────────────
let ws = null;
let retryDelay = 1000;

View File

@@ -116,6 +116,7 @@
<!-- ── 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>

View File

@@ -139,6 +139,9 @@ 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; }
@@ -237,9 +240,40 @@ body.state-offline #llama { opacity: 0.15; }
color: var(--amber-dim);
}
/* ── 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 {
flex: 0 0 220px;
position: relative;
flex: 0 0 300px;
background: #0d0d06;
border: 3px solid var(--amber-dim);
border-radius: 12px;