Replace GoAccess with built-in log viewer, clickable error links
New /logs page with 5 tabs: - App Log (journalctl for llm-team-ui service) - Run History (all completed runs with errors inline) - Nginx Errors (with red highlighting) - Nginx Access (with color-coded status codes) - Security Log (fail2ban/exploit detection) Features: - Live text filter (grep-style) - Configurable line limit (50-500) - Auto-refresh every 10s - Run history shows mode, user, duration, response count, errors - Error lines highlighted red, warnings amber - Status codes color-coded (2xx green, 3xx blue, 4xx amber, 5xx red) Error linking: - Stream errors in main UI link to /admin/monitor - Error response cards have "View error details" link - Error cards styled with red border and monospace body Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
59379c624d
commit
344e11f4b2
240
llm_team_ui.py
240
llm_team_ui.py
@ -480,14 +480,223 @@ def demo_set_allowlist():
|
||||
|
||||
|
||||
@app.route("/logs")
|
||||
@admin_required
|
||||
def logs_page():
|
||||
if not is_admin():
|
||||
return redirect("/login")
|
||||
return LOGS_HTML
|
||||
|
||||
@app.route("/api/admin/logs")
|
||||
@admin_required
|
||||
def admin_logs():
|
||||
source = request.args.get("source", "app")
|
||||
limit = min(int(request.args.get("limit", 100)), 500)
|
||||
lines = []
|
||||
try:
|
||||
with open("/var/www/html/report.html") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return "GoAccess report not found. Run: goaccess /var/log/nginx/access.log -o /var/www/html/report.html --log-format=COMBINED", 404
|
||||
if source == "nginx_access":
|
||||
with open("/var/log/nginx/access.log") as f:
|
||||
lines = f.readlines()[-limit:]
|
||||
elif source == "nginx_error":
|
||||
with open("/var/log/nginx/error.log") as f:
|
||||
lines = f.readlines()[-limit:]
|
||||
elif source == "security":
|
||||
with open("/var/log/llm-team-security.log") as f:
|
||||
lines = f.readlines()[-limit:]
|
||||
elif source == "runs":
|
||||
return jsonify({"lines": [], "runs": list(reversed(_run_log[-limit:]))})
|
||||
else:
|
||||
# App log — get from journalctl
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["journalctl", "-u", "llm-team-ui", "--no-pager", "-n", str(limit), "--output=short-iso"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
lines = result.stdout.strip().split("\n") if result.stdout else []
|
||||
except Exception as e:
|
||||
lines = [f"Error reading log: {e}"]
|
||||
return jsonify({"lines": [l.rstrip() for l in lines]})
|
||||
|
||||
|
||||
LOGS_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>LLM Team — Logs</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#08090c;--surface:rgba(14,16,22,0.85);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;--accent:#e2b55a;--green:#4ade80;--red:#e05252;--blue:#5b9cf5}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 28px}
|
||||
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
|
||||
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.02) 2px,rgba(0,0,0,0.02) 4px)}
|
||||
.wrap{position:relative;z-index:10;max-width:1400px;margin:0 auto}
|
||||
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:20px}
|
||||
h1{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:700}
|
||||
h1 span{color:var(--accent)}
|
||||
.back{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:1px;border:2px solid var(--border);padding:5px 12px;border-radius:2px;margin-left:auto}
|
||||
.back:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.tab{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;padding:8px 16px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text2);cursor:pointer;transition:all 0.15s}
|
||||
.tab:hover{border-color:var(--accent);color:var(--text)}
|
||||
.tab.active{border-color:var(--accent);color:var(--accent);background:rgba(226,181,90,0.06)}
|
||||
.tab.err{border-color:rgba(224,82,82,0.3);color:var(--red)}
|
||||
.tab.err.active{background:rgba(224,82,82,0.06)}
|
||||
.controls{display:flex;gap:8px;align-items:center;margin-bottom:12px}
|
||||
.controls label{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;color:var(--text2)}
|
||||
.controls select,.controls input{background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px;font-size:11px;font-family:'JetBrains Mono',monospace}
|
||||
.controls button{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;padding:6px 14px;border:2px solid var(--accent);border-radius:2px;background:var(--accent);color:#08090c;cursor:pointer;font-weight:700}
|
||||
.controls button:hover{background:var(--accent2)}
|
||||
.log-view{background:rgba(0,0,0,0.4);border:2px solid var(--border);border-radius:2px;padding:0;font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.7;overflow:auto;max-height:calc(100vh - 220px);backdrop-filter:blur(16px)}
|
||||
.log-line{padding:2px 14px;border-bottom:1px solid rgba(42,45,53,0.3);white-space:pre-wrap;word-break:break-all}
|
||||
.log-line:hover{background:rgba(226,181,90,0.03)}
|
||||
.log-line.err{color:var(--red);background:rgba(224,82,82,0.04)}
|
||||
.log-line.warn{color:#f59e0b}
|
||||
.log-line.info{color:var(--text2)}
|
||||
.log-line .ts{color:var(--text2);opacity:0.5;margin-right:8px}
|
||||
.log-line .status-2xx{color:var(--green)}
|
||||
.log-line .status-3xx{color:var(--blue)}
|
||||
.log-line .status-4xx{color:#f59e0b}
|
||||
.log-line .status-5xx{color:var(--red)}
|
||||
.run-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;padding:12px;margin-bottom:6px}
|
||||
.run-card.has-errors{border-color:var(--red)}
|
||||
.run-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
|
||||
.tag{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:3px 8px;border:1px solid;border-radius:1px;font-weight:600}
|
||||
.tag-mode{color:var(--accent);border-color:rgba(226,181,90,0.3)}
|
||||
.tag-time{color:var(--text2);border-color:var(--border)}
|
||||
.tag-ok{color:var(--green);border-color:rgba(74,222,128,0.3)}
|
||||
.tag-err{color:var(--red);border-color:rgba(224,82,82,0.3)}
|
||||
.run-prompt{font-size:11px;color:var(--text2);margin:4px 0}
|
||||
.run-error{font-size:10px;color:var(--red);border-left:2px solid var(--red);padding-left:8px;margin:4px 0}
|
||||
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
|
||||
.filter-input{flex:1}
|
||||
</style>
|
||||
</head><body>
|
||||
<canvas id="bg-grid"></canvas>
|
||||
<div class="scanlines"></div>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1><span>Logs</span> // System View</h1>
|
||||
<a class="back" href="/admin/monitor">Monitor</a>
|
||||
<a class="back" href="/admin">Admin</a>
|
||||
<a class="back" href="/">← Team</a>
|
||||
</header>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tab active" data-src="app" onclick="switchTab(this)">App Log</div>
|
||||
<div class="tab" data-src="runs" onclick="switchTab(this)">Run History</div>
|
||||
<div class="tab err" data-src="nginx_error" onclick="switchTab(this)">Nginx Errors</div>
|
||||
<div class="tab" data-src="nginx_access" onclick="switchTab(this)">Nginx Access</div>
|
||||
<div class="tab err" data-src="security" onclick="switchTab(this)">Security</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>Lines:</label>
|
||||
<select id="log-limit" onchange="loadLogs()">
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
<label>Filter:</label>
|
||||
<input class="filter-input" id="log-filter" placeholder="grep..." oninput="filterLogs()">
|
||||
<button onclick="loadLogs()">Refresh</button>
|
||||
</div>
|
||||
<div class="log-view" id="log-view"><div class="empty">Loading...</div></div>
|
||||
</div>
|
||||
<script>
|
||||
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
|
||||
|
||||
var currentSource = 'app';
|
||||
var allLines = [];
|
||||
|
||||
function switchTab(el) {
|
||||
document.querySelectorAll('.tab').forEach(function(t){t.classList.remove('active')});
|
||||
el.classList.add('active');
|
||||
currentSource = el.dataset.src;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function esc(t){var d=document.createElement('span');d.textContent=t;return d.innerHTML}
|
||||
|
||||
function classifyLine(line) {
|
||||
var lower = line.toLowerCase();
|
||||
if (lower.indexOf('error') >= 0 || lower.indexOf('fail') >= 0 || lower.indexOf('traceback') >= 0) return 'err';
|
||||
if (lower.indexOf('warn') >= 0 || lower.indexOf(' 4') >= 0) return 'warn';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function highlightStatus(text) {
|
||||
return text.replace(/\s(2\d\d)\s/g, ' <span class="status-2xx">$1</span> ')
|
||||
.replace(/\s(3\d\d)\s/g, ' <span class="status-3xx">$1</span> ')
|
||||
.replace(/\s(4\d\d)\s/g, ' <span class="status-4xx">$1</span> ')
|
||||
.replace(/\s(5\d\d)\s/g, ' <span class="status-5xx">$1</span> ');
|
||||
}
|
||||
|
||||
function renderLines(lines) {
|
||||
var view = document.getElementById('log-view');
|
||||
if (!lines.length) { view.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No log entries'; view.appendChild(e); return; }
|
||||
view.textContent = '';
|
||||
lines.forEach(function(line) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'log-line ' + classifyLine(line);
|
||||
div.innerHTML = highlightStatus(esc(line));
|
||||
view.appendChild(div);
|
||||
});
|
||||
view.scrollTop = view.scrollHeight;
|
||||
}
|
||||
|
||||
function renderRuns(runs) {
|
||||
var view = document.getElementById('log-view');
|
||||
if (!runs.length) { view.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No run history'; view.appendChild(e); return; }
|
||||
view.textContent = '';
|
||||
runs.forEach(function(r) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'run-card' + (r.errors && r.errors.length ? ' has-errors' : '');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'run-row';
|
||||
function addTag(text, cls) { var t = document.createElement('span'); t.className = 'tag ' + cls; t.textContent = text; row.appendChild(t); }
|
||||
addTag(r.mode, 'tag-mode');
|
||||
addTag(r.user || '?', 'tag-time');
|
||||
if (r.duration) addTag(r.duration + 's', 'tag-time');
|
||||
addTag((r.response_count || 0) + ' responses', 'tag-time');
|
||||
if (r.errors && r.errors.length) addTag(r.errors.length + ' errors', 'tag-err');
|
||||
else addTag('ok', 'tag-ok');
|
||||
card.appendChild(row);
|
||||
var p = document.createElement('div'); p.className = 'run-prompt'; p.textContent = r.prompt || ''; card.appendChild(p);
|
||||
if (r.errors) r.errors.forEach(function(e) {
|
||||
var el = document.createElement('div'); el.className = 'run-error';
|
||||
el.textContent = (e.model || '?') + ': ' + (e.error || 'unknown');
|
||||
card.appendChild(el);
|
||||
});
|
||||
view.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function filterLogs() {
|
||||
var q = document.getElementById('log-filter').value.toLowerCase();
|
||||
if (!q) { renderLines(allLines); return; }
|
||||
renderLines(allLines.filter(function(l) { return l.toLowerCase().indexOf(q) >= 0; }));
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
var limit = document.getElementById('log-limit').value;
|
||||
var view = document.getElementById('log-view');
|
||||
view.textContent = '';
|
||||
var ld = document.createElement('div'); ld.className = 'empty'; ld.textContent = 'Loading...'; view.appendChild(ld);
|
||||
try {
|
||||
var r = await fetch('/api/admin/logs?source=' + currentSource + '&limit=' + limit);
|
||||
var d = await r.json();
|
||||
if (currentSource === 'runs') {
|
||||
renderRuns(d.runs || []);
|
||||
} else {
|
||||
allLines = d.lines || [];
|
||||
filterLogs();
|
||||
}
|
||||
} catch(e) {
|
||||
view.textContent = '';
|
||||
var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error loading logs: ' + e.message; view.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
loadLogs();
|
||||
setInterval(function() { if (currentSource !== 'runs') loadLogs(); }, 10000);
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
CONFIG_PATH = "/root/llm_team_config.json"
|
||||
@ -640,6 +849,11 @@ HTML = r"""
|
||||
.synthesis-card { border-color: var(--accent); }
|
||||
.synthesis-card .card-header { background: var(--glow); }
|
||||
.synthesis-card::before { content: ''; position: absolute; top: -1px; left: 0; right: 0; height: 1px; background: var(--accent); opacity: 0.3; }
|
||||
.error-card { border-color: var(--red); }
|
||||
.error-card .card-header { background: rgba(224,82,82,0.08); }
|
||||
.error-card .card-body { color: var(--red); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
.error-card .error-link { display: block; padding: 6px 14px 10px; font-family: 'JetBrains Mono', monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; color: var(--red); opacity: 0.6; text-decoration: none; }
|
||||
.error-card .error-link:hover { opacity: 1; text-decoration: underline; }
|
||||
.crazy-card { border-color: #a855f7; }
|
||||
.crazy-card .card-header { background: rgba(168,85,247,0.08); }
|
||||
.status-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: rgba(0,0,0,0.3); border: 2px solid var(--border); border-radius: 2px; font-size: 11px; color: var(--text2); font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
@ -1459,11 +1673,11 @@ async function runTeam() {
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
const errDiv = document.createElement('div');
|
||||
var errDiv = document.createElement('a');
|
||||
errDiv.className = 'status-bar';
|
||||
errDiv.style.color = 'var(--red)';
|
||||
errDiv.style.borderColor = 'var(--red)';
|
||||
errDiv.textContent = 'Error: ' + e.message + ' (after ' + formatElapsed(Date.now() - _runStartTime) + ')';
|
||||
errDiv.href = '/admin/monitor';
|
||||
errDiv.style.cssText = 'color:var(--red);border-color:var(--red);text-decoration:none;cursor:pointer;display:flex';
|
||||
errDiv.textContent = 'Error: ' + e.message + ' (after ' + formatElapsed(Date.now() - _runStartTime) + ') — click to view logs';
|
||||
output.appendChild(errDiv);
|
||||
}
|
||||
clearInterval(_runTimer);
|
||||
@ -1554,13 +1768,15 @@ function handleEvent(evt) {
|
||||
const mi = availableModels.findIndex(m => m.name === evt.model);
|
||||
const color = COLORS[(mi >= 0 ? mi : 0) % COLORS.length];
|
||||
const displayName = mi >= 0 ? (availableModels[mi].display_name || evt.model) : evt.model;
|
||||
const isError = evt.role === 'error';
|
||||
const hl = ['synthesis','judge','verdict','final','consensus','patcher','assembler','analyzer','survivor','mesh-360'].includes(evt.role);
|
||||
const isCrazy = evt.role && (evt.role.includes('catastrophe') || evt.role.includes('chaos') || evt.role === 'survivor');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'output-card' + (hl ? ' synthesis-card' : '') + (isCrazy ? ' crazy-card' : '');
|
||||
card.className = 'output-card' + (isError ? ' error-card' : '') + (hl ? ' synthesis-card' : '') + (isCrazy ? ' crazy-card' : '');
|
||||
const roleTag = evt.role ? `<span class="role-tag">${evt.role}</span>` : '';
|
||||
const uid = 'resp-' + Date.now() + '-' + Math.random().toString(36).substr(2,4);
|
||||
card.innerHTML = `<div class="card-header" style="cursor:pointer" onclick="openRepipe('${uid}')"><div class="dot" style="background:${color}"></div>${displayName}${roleTag}</div><div class="card-body" id="${uid}">${escapeHtml(evt.text)}</div><div class="card-actions"><button class="card-act" onclick="event.stopPropagation();copyCard('${uid}',this)">Copy</button><button class="card-act" onclick="event.stopPropagation();useAsPrompt('${uid}')">Use as Prompt</button><button class="card-act" onclick="event.stopPropagation();openRepipe('${uid}')">Iterate</button></div>`;
|
||||
const errorLink = isError ? `<a class="error-link" href="/admin/monitor">View error details in monitor →</a>` : '';
|
||||
card.innerHTML = `<div class="card-header" style="cursor:pointer" onclick="openRepipe('${uid}')"><div class="dot" style="background:${isError ? 'var(--red)' : color}"></div>${displayName}${roleTag}</div><div class="card-body" id="${uid}">${escapeHtml(evt.text)}</div>${errorLink}<div class="card-actions"><button class="card-act" onclick="event.stopPropagation();copyCard('${uid}',this)">Copy</button><button class="card-act" onclick="event.stopPropagation();useAsPrompt('${uid}')">Use as Prompt</button><button class="card-act" onclick="event.stopPropagation();openRepipe('${uid}')">Iterate</button></div>`;
|
||||
card.dataset.model = evt.model;
|
||||
card.dataset.role = evt.role || '';
|
||||
card.dataset.displayName = displayName;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user