v1.3.1: System stats panel (CPU, memory, GPU, VRAM)

- Add /api/stats endpoint using psutil + rocm-smi

- Live-updating bars in sidebar footer (2s interval)

- Color-coded: green/yellow/red based on usage

- Graceful fallback when rocm-smi unavailable

- Updated readme with venv installation instructions

- Requires: psutil
This commit is contained in:
2026-03-15 09:49:03 -07:00
parent 46cccc9087
commit 4646d82c66
2 changed files with 169 additions and 9 deletions

150
app.py
View File

@@ -18,6 +18,7 @@ import json
import logging import logging
import math import math
import sqlite3 import sqlite3
import subprocess
import uuid import uuid
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -25,6 +26,7 @@ from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import httpx import httpx
import psutil
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
@@ -38,7 +40,7 @@ syslog_handler.setFormatter(logging.Formatter('jarvischat[%(process)d]: %(leveln
log.addHandler(syslog_handler) log.addHandler(syslog_handler)
# --- Configuration --- # --- Configuration ---
VERSION = "1.3.0" VERSION = "1.3.1"
OLLAMA_BASE = "http://localhost:11434" OLLAMA_BASE = "http://localhost:11434"
SEARXNG_BASE = "http://localhost:8888" SEARXNG_BASE = "http://localhost:8888"
DB_PATH = Path(__file__).parent / "jarvischat.db" DB_PATH = Path(__file__).parent / "jarvischat.db"
@@ -429,6 +431,72 @@ async def search_status():
except: except:
return {"available": False} return {"available": False}
# --- System Stats ---
def get_gpu_stats() -> dict:
"""Get AMD GPU stats via rocm-smi."""
try:
result = subprocess.run(
["rocm-smi", "--showuse", "--showmemuse", "--json"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
data = json.loads(result.stdout)
# Parse rocm-smi JSON output
gpu_info = data.get("card0", {})
gpu_use = gpu_info.get("GPU use (%)", 0)
vram_use = gpu_info.get("GPU Memory Allocated (VRAM%)", 0)
# Handle string or int values
if isinstance(gpu_use, str):
gpu_use = int(gpu_use.replace("%", "").strip() or 0)
if isinstance(vram_use, str):
vram_use = int(vram_use.replace("%", "").strip() or 0)
return {"gpu_percent": gpu_use, "vram_percent": vram_use, "available": True}
except subprocess.TimeoutExpired:
log.warning("rocm-smi timed out")
except FileNotFoundError:
log.debug("rocm-smi not found")
except json.JSONDecodeError:
# Fallback: parse text output
try:
result = subprocess.run(
["rocm-smi", "--showuse", "--showmemuse"],
capture_output=True, text=True, timeout=5
)
gpu_use = 0
vram_use = 0
for line in result.stdout.split("\n"):
if "GPU use (%)" in line:
match = re.search(r"(\d+)", line.split(":")[-1])
if match:
gpu_use = int(match.group(1))
elif "GPU Memory Allocated (VRAM%)" in line:
match = re.search(r"(\d+)", line.split(":")[-1])
if match:
vram_use = int(match.group(1))
return {"gpu_percent": gpu_use, "vram_percent": vram_use, "available": True}
except Exception as e:
log.warning(f"rocm-smi parse error: {e}")
except Exception as e:
log.warning(f"GPU stats error: {e}")
return {"gpu_percent": 0, "vram_percent": 0, "available": False}
@app.get("/api/stats")
async def system_stats():
"""Get system resource usage (CPU, memory, GPU)."""
cpu_percent = psutil.cpu_percent(interval=0.1)
memory = psutil.virtual_memory()
gpu = get_gpu_stats()
return {
"cpu_percent": round(cpu_percent, 1),
"memory_percent": round(memory.percent, 1),
"memory_used_gb": round(memory.used / (1024**3), 1),
"memory_total_gb": round(memory.total / (1024**3), 1),
"gpu_percent": gpu["gpu_percent"],
"vram_percent": gpu["vram_percent"],
"gpu_available": gpu["available"],
}
# --- Profile --- # --- Profile ---
@app.get("/api/profile") @app.get("/api/profile")
@@ -870,6 +938,15 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
.conv-item .conv-delete:hover { opacity: 1; } .conv-item .conv-delete:hover { opacity: 1; }
.sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); } .sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); }
.sidebar-footer .status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .sidebar-footer .status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.stats-panel { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.stat-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.stat-label { width: 36px; font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.stat-bar { flex: 1; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; }
.stat-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s ease, background 0.3s ease; width: 0%; }
.stat-fill.gpu { background: var(--success); }
.stat-fill.warn { background: var(--warning); }
.stat-fill.danger { background: var(--danger); }
.stat-value { width: 32px; text-align: right; font-size: 10px; }
/* Main */ /* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; } .main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; }
@@ -999,6 +1076,28 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="status-row" id="ollamaStatus"><span class="status-dot offline"></span> checking...</div> <div class="status-row" id="ollamaStatus"><span class="status-dot offline"></span> checking...</div>
<div class="status-row" id="searchStatus"><span class="status-dot offline"></span> search: checking...</div> <div class="status-row" id="searchStatus"><span class="status-dot offline"></span> search: checking...</div>
<div class="stats-panel" id="statsPanel">
<div class="stat-row">
<span class="stat-label">CPU</span>
<div class="stat-bar"><div class="stat-fill" id="cpuFill"></div></div>
<span class="stat-value" id="cpuValue">--%</span>
</div>
<div class="stat-row">
<span class="stat-label">MEM</span>
<div class="stat-bar"><div class="stat-fill" id="memFill"></div></div>
<span class="stat-value" id="memValue">--%</span>
</div>
<div class="stat-row">
<span class="stat-label">GPU</span>
<div class="stat-bar"><div class="stat-fill gpu" id="gpuFill"></div></div>
<span class="stat-value" id="gpuValue">--%</span>
</div>
<div class="stat-row">
<span class="stat-label">VRAM</span>
<div class="stat-bar"><div class="stat-fill gpu" id="vramFill"></div></div>
<span class="stat-value" id="vramValue">--%</span>
</div>
</div>
</div> </div>
</aside> </aside>
@@ -1110,12 +1209,61 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadConversations(); await loadConversations();
checkOllamaStatus(); checkOllamaStatus();
checkSearchStatus(); checkSearchStatus();
updateSystemStats();
setInterval(checkOllamaStatus, 30000); setInterval(checkOllamaStatus, 30000);
setInterval(checkSearchStatus, 60000); setInterval(checkSearchStatus, 60000);
setInterval(updateSystemStats, 2000);
document.getElementById('userInput').addEventListener('input', updateTokenThermometer); document.getElementById('userInput').addEventListener('input', updateTokenThermometer);
updateTokenThermometer(); updateTokenThermometer();
}); });
async function updateSystemStats() {
try {
const resp = await fetch('/api/stats');
const data = await resp.json();
// Update CPU
const cpuFill = document.getElementById('cpuFill');
const cpuValue = document.getElementById('cpuValue');
cpuFill.style.width = data.cpu_percent + '%';
cpuFill.className = 'stat-fill' + (data.cpu_percent >= 90 ? ' danger' : data.cpu_percent >= 70 ? ' warn' : '');
cpuValue.textContent = data.cpu_percent + '%';
// Update Memory
const memFill = document.getElementById('memFill');
const memValue = document.getElementById('memValue');
memFill.style.width = data.memory_percent + '%';
memFill.className = 'stat-fill' + (data.memory_percent >= 90 ? ' danger' : data.memory_percent >= 70 ? ' warn' : '');
memValue.textContent = data.memory_percent + '%';
// Update GPU
const gpuFill = document.getElementById('gpuFill');
const gpuValue = document.getElementById('gpuValue');
if (data.gpu_available) {
gpuFill.style.width = data.gpu_percent + '%';
gpuFill.className = 'stat-fill gpu' + (data.gpu_percent >= 90 ? ' danger' : data.gpu_percent >= 70 ? ' warn' : '');
gpuValue.textContent = data.gpu_percent + '%';
} else {
gpuFill.style.width = '0%';
gpuValue.textContent = 'N/A';
}
// Update VRAM
const vramFill = document.getElementById('vramFill');
const vramValue = document.getElementById('vramValue');
if (data.gpu_available) {
vramFill.style.width = data.vram_percent + '%';
vramFill.className = 'stat-fill gpu' + (data.vram_percent >= 90 ? ' danger' : data.vram_percent >= 70 ? ' warn' : '');
vramValue.textContent = data.vram_percent + '%';
} else {
vramFill.style.width = '0%';
vramValue.textContent = 'N/A';
}
} catch(e) {
console.log('Stats fetch error:', e);
}
}
async function checkOllamaStatus() { async function checkOllamaStatus() {
try { try {
const resp = await fetch('/api/ps'); const resp = await fetch('/api/ps');

View File

@@ -2,7 +2,7 @@
**A lightweight Ollama coding companion that runs on Python 3.13** **A lightweight Ollama coding companion that runs on Python 3.13**
![Version](https://img.shields.io/badge/version-1.3.0-blue) ![Version](https://img.shields.io/badge/version-1.3.1-blue)
![Python](https://img.shields.io/badge/python-3.13-green) ![Python](https://img.shields.io/badge/python-3.13-green)
![License](https://img.shields.io/badge/license-MIT-orange) ![License](https://img.shields.io/badge/license-MIT-orange)
@@ -39,16 +39,21 @@ JarvisChat acts as middleware between your browser and Ollama. When the model's
- Python 3.11+ (tested on 3.13) - Python 3.11+ (tested on 3.13)
- Ollama running locally (default: `localhost:11434`) - Ollama running locally (default: `localhost:11434`)
- SearXNG (optional, for web search — default: `localhost:8888`) - SearXNG (optional, for web search — default: `localhost:8888`)
- ROCm (optional, for AMD GPU stats — `rocm-smi` must be in PATH)
## Installation ## Installation
```bash ```bash
# Clone or download app.py # Clone or download app.py
git clone https://llgit.llamachile.shop/gramps/jarvischat.git git clone https://github.com/llamachileshop-code/313_webui.git
cd jarvischat cd 313_webui
# Create virtual environment (recommended)
python3 -m venv venv
source venv/bin/activate
# Install dependencies # Install dependencies
pip install fastapi httpx uvicorn pip install fastapi httpx uvicorn psutil
# Run # Run
python app.py python app.py
@@ -58,6 +63,11 @@ uvicorn app:app --host 0.0.0.0 --port 8080
Open `http://localhost:8080` in your browser. Open `http://localhost:8080` in your browser.
**Note:** If running as a systemd service with a venv, install dependencies using the venv pip directly:
```bash
/opt/jarvischat/venv/bin/pip install fastapi httpx uvicorn psutil
```
## Running as a Service ## Running as a Service
**Important:** Although JarvisChat is a single-file Python application, it's designed to run as a persistent service alongside Ollama — not as a one-off script. Both services should start on boot. **Important:** Although JarvisChat is a single-file Python application, it's designed to run as a persistent service alongside Ollama — not as a one-off script. Both services should start on boot.
@@ -74,8 +84,8 @@ Wants=ollama.service
[Service] [Service]
Type=simple Type=simple
User=jarvischat User=your-username
WorkingDirectory=/opt/jarvischat WorkingDirectory=/path/to/313_webui
ExecStart=/usr/bin/python3 app.py ExecStart=/usr/bin/python3 app.py
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
@@ -110,7 +120,7 @@ journalctl -t jarvischat -f
Edit these constants at the top of `app.py`: Edit these constants at the top of `app.py`:
```python ```python
VERSION = "1.3.0" VERSION = "1.3.1"
OLLAMA_BASE = "http://localhost:11434" OLLAMA_BASE = "http://localhost:11434"
SEARXNG_BASE = "http://localhost:8888" SEARXNG_BASE = "http://localhost:8888"
DEFAULT_MODEL = "deepseek-coder:6.7b" DEFAULT_MODEL = "deepseek-coder:6.7b"
@@ -172,8 +182,9 @@ The count includes: profile + preset + conversation history + current input. Con
| `/api/models` | GET | List Ollama models | | `/api/models` | GET | List Ollama models |
| `/api/ps` | GET | Running models | | `/api/ps` | GET | Running models |
| `/api/show` | POST | Model info (context size) | | `/api/show` | POST | Model info (context size) |
| `/api/stats` | GET | System stats (CPU, memory, GPU, VRAM) |
| `/api/chat` | POST | Stream chat (SSE) | | `/api/chat` | POST | Stream chat (SSE) |
| `/api/conversations` | GET | List conversations | | `/api/conversations` | GET/DELETE | List/delete all conversations |
| `/api/conversations/{id}` | GET/DELETE | Get/delete conversation | | `/api/conversations/{id}` | GET/DELETE | Get/delete conversation |
| `/api/profile` | GET/PUT | Get/update profile | | `/api/profile` | GET/PUT | Get/update profile |
| `/api/presets` | GET/POST | List/create presets | | `/api/presets` | GET/POST | List/create presets |
@@ -222,6 +233,7 @@ The count includes: profile + preset + conversation history + current input. Con
| Version | Changes | | Version | Changes |
|---------|---------| |---------|---------|
| 1.3.1 | System stats panel (CPU, memory, GPU, VRAM) in sidebar |
| 1.3.0 | Delete all conversations button | | 1.3.0 | Delete all conversations button |
| 1.2.9 | Token thermometer with live context tracking | | 1.2.9 | Token thermometer with live context tracking |
| 1.2.8 | Logo in sidebar, llama emoji tagline | | 1.2.8 | Logo in sidebar, llama emoji tagline |