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:
root 2026-03-26 02:35:17 -05:00
parent 59379c624d
commit 344e11f4b2

View File

@ -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;