troutbot/src/dashboard.ts
NotAShelf 2db5fa502f
dashboard: add authentication middleware
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9910548c65a11f2c83dfeb4fef3c93f06a6a6964
2026-02-01 17:17:32 +03:00

350 lines
11 KiB
TypeScript

import express from 'express';
import type { Config } from './types.js';
import { getRecentEvents, clearEvents } from './events.js';
import { validate, deepMerge } from './config.js';
export function createDashboardRouter(config: Config): express.Router {
const router = express.Router();
const startTime = Date.now();
router.use(express.json());
// Authentication middleware
if (config.dashboard?.auth) {
router.use((req, res, next) => {
const auth = config.dashboard!.auth!;
if (auth.type === 'basic') {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="Troutbot Dashboard"');
res.status(401).json({ error: 'Authentication required' });
return;
}
const credentials = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
const [username, password] = credentials.split(':');
if (username !== auth.username || password !== auth.password) {
res.setHeader('WWW-Authenticate', 'Basic realm="Troutbot Dashboard"');
res.status(401).json({ error: 'Invalid credentials' });
return;
}
} else if (auth.type === 'token') {
const authHeader = req.headers.authorization;
const token = req.query.token as string | undefined;
const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : token;
if (!providedToken || providedToken !== auth.token) {
res.status(401).json({ error: 'Invalid or missing token' });
return;
}
}
next();
});
}
// API routes
router.get('/api/status', (_req, res) => {
const enabledBackends = Object.entries(config.engine.backends)
.filter(([, v]) => v.enabled)
.map(([k]) => k);
res.json({
uptime: Math.floor((Date.now() - startTime) / 1000),
version: process.env.npm_package_version ?? 'unknown',
dryRun: !process.env.GITHUB_TOKEN,
backends: enabledBackends,
repoCount: config.repositories.length || 'all',
});
});
router.get('/api/events', (_req, res) => {
res.json(getRecentEvents());
});
router.delete('/api/events', (_req, res) => {
clearEvents();
res.json({ cleared: true });
});
router.get('/api/config', (_req, res) => {
res.json(config);
});
router.put('/api/config', (req, res) => {
try {
const partial = req.body as Partial<Config>;
const merged = deepMerge(
config as unknown as Record<string, unknown>,
partial as unknown as Record<string, unknown>
) as unknown as Config;
validate(merged);
// Apply in-place
Object.assign(config, merged);
res.json(config);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.status(400).json({ error: message });
}
});
// Dashboard HTML
router.get('/dashboard', (_req, res) => {
res.type('html').send(dashboardHTML());
});
return router;
}
function dashboardHTML(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Troutbot Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117; color: #c9d1d9; line-height: 1.5;
padding: 1.5rem; max-width: 1200px; margin: 0 auto;
}
h1 { color: #58a6ff; margin-bottom: 1.5rem; font-size: 1.5rem; }
h2 { color: #8b949e; font-size: 1rem; text-transform: uppercase;
letter-spacing: 0.05em; margin-bottom: 0.75rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
padding: 1rem 1.25rem; margin-bottom: 1.5rem;
}
.status-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
.status-item label { display: block; color: #8b949e; font-size: 0.75rem; }
.status-item span { font-size: 1.1rem; font-weight: 600; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; color: #8b949e; font-weight: 600; padding: 0.5rem 0.75rem;
border-bottom: 1px solid #30363d; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; }
tr:hover td { background: #1c2128; }
.impact-positive { color: #3fb950; }
.impact-negative { color: #f85149; }
.impact-neutral { color: #8b949e; }
.config-view {
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, monospace;
font-size: 0.8rem; background: #0d1117; color: #c9d1d9;
border: 1px solid #30363d; border-radius: 4px; padding: 1rem;
white-space: pre-wrap; word-break: break-word; min-height: 200px;
width: 100%; resize: vertical;
}
.btn {
background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
border-radius: 4px; padding: 0.4rem 1rem; cursor: pointer;
font-size: 0.85rem; margin-right: 0.5rem; margin-top: 0.5rem;
}
.btn:hover { background: #30363d; }
.btn-primary { background: #238636; border-color: #2ea043; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; border-color: #f85149; }
.btn-danger:hover { background: #f85149; }
.msg { margin-top: 0.5rem; font-size: 0.85rem; }
.msg-ok { color: #3fb950; }
.msg-err { color: #f85149; }
.empty { color: #484f58; font-style: italic; padding: 1rem 0; }
</style>
</head>
<body>
<h1>Troutbot Dashboard</h1>
<!-- Status card -->
<div class="card" id="status-card">
<h2>Status</h2>
<div class="status-grid" id="status-grid">
<div class="status-item"><label>Loading...</label></div>
</div>
</div>
<!-- Recent events -->
<div class="card">
<h2>Recent Events</h2>
<div style="overflow-x:auto">
<table>
<thead><tr>
<th>ID</th><th>Time</th><th>Repo</th><th>#</th>
<th>Action</th><th>Impact</th><th>Confidence</th><th>Result</th>
</tr></thead>
<tbody id="events-body">
<tr><td colspan="8" class="empty">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Config editor -->
<div class="card">
<h2>Configuration</h2>
<div id="config-container">
<pre class="config-view" id="config-view"></pre>
<div>
<button class="btn" id="edit-btn" onclick="toggleEdit()">Edit</button>
<button class="btn btn-primary" id="save-btn" style="display:none" onclick="saveConfig()">Save</button>
<button class="btn" id="cancel-btn" style="display:none" onclick="cancelEdit()">Cancel</button>
</div>
<div class="msg" id="config-msg"></div>
</div>
</div>
<script>
let currentConfig = null;
let editing = false;
async function fetchStatus() {
try {
const r = await fetch('/api/status');
const d = await r.json();
const grid = document.getElementById('status-grid');
const upH = Math.floor(d.uptime / 3600);
const upM = Math.floor((d.uptime % 3600) / 60);
const upS = d.uptime % 60;
grid.innerHTML = [
item('Uptime', upH + 'h ' + upM + 'm ' + upS + 's'),
item('Version', d.version),
item('Dry Run', d.dryRun ? 'Yes' : 'No'),
item('Backends', d.backends.join(', ')),
item('Repos', d.repoCount),
].join('');
} catch(e) { console.error('Status fetch failed', e); }
}
function item(label, value) {
return '<div class="status-item"><label>' + label + '</label><span>' + value + '</span></div>';
}
async function fetchEvents() {
try {
const r = await fetch('/api/events');
const events = await r.json();
const tbody = document.getElementById('events-body');
if (!events.length) {
tbody.innerHTML = '<tr><td colspan="8" class="empty">No events recorded yet</td></tr>';
return;
}
tbody.innerHTML = events.map(function(e) {
var impact = e.analysis ? e.analysis.impact : (e.result.skipped ? 'neutral' : '—');
var conf = e.analysis ? e.analysis.confidence.toFixed(2) : '—';
var result = e.result.skipped ? 'skipped: ' + (e.result.reason || '') : 'processed';
var time = new Date(e.timestamp).toLocaleTimeString();
return '<tr>'
+ '<td>' + e.id + '</td>'
+ '<td>' + time + '</td>'
+ '<td>' + e.event.owner + '/' + e.event.repo + '</td>'
+ '<td>' + e.event.number + '</td>'
+ '<td>' + e.event.action + '</td>'
+ '<td class="impact-' + impact + '">' + impact + '</td>'
+ '<td>' + conf + '</td>'
+ '<td>' + result + '</td>'
+ '</tr>';
}).join('');
} catch(e) { console.error('Events fetch failed', e); }
}
async function fetchConfig() {
try {
const r = await fetch('/api/config');
currentConfig = await r.json();
if (!editing) renderConfig();
} catch(e) { console.error('Config fetch failed', e); }
}
function renderConfig() {
var el = document.getElementById('config-view');
el.textContent = JSON.stringify(currentConfig, null, 2);
}
function toggleEdit() {
editing = true;
var container = document.getElementById('config-container');
var pre = document.getElementById('config-view');
var ta = document.createElement('textarea');
ta.className = 'config-view';
ta.id = 'config-view';
ta.value = JSON.stringify(currentConfig, null, 2);
container.replaceChild(ta, pre);
document.getElementById('edit-btn').style.display = 'none';
document.getElementById('save-btn').style.display = '';
document.getElementById('cancel-btn').style.display = '';
document.getElementById('config-msg').textContent = '';
}
function cancelEdit() {
editing = false;
var container = document.getElementById('config-container');
var ta = document.getElementById('config-view');
var pre = document.createElement('pre');
pre.className = 'config-view';
pre.id = 'config-view';
pre.textContent = JSON.stringify(currentConfig, null, 2);
container.replaceChild(pre, ta);
document.getElementById('edit-btn').style.display = '';
document.getElementById('save-btn').style.display = 'none';
document.getElementById('cancel-btn').style.display = 'none';
document.getElementById('config-msg').textContent = '';
}
async function saveConfig() {
var msg = document.getElementById('config-msg');
var ta = document.getElementById('config-view');
var text = ta.value;
try {
var parsed = JSON.parse(text);
} catch(e) {
msg.className = 'msg msg-err';
msg.textContent = 'Invalid JSON: ' + e.message;
return;
}
try {
var r = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed),
});
var data = await r.json();
if (!r.ok) {
msg.className = 'msg msg-err';
msg.textContent = 'Error: ' + (data.error || 'Unknown error');
return;
}
currentConfig = data;
msg.className = 'msg msg-ok';
msg.textContent = 'Config saved successfully';
cancelEdit();
} catch(e) {
msg.className = 'msg msg-err';
msg.textContent = 'Request failed: ' + e.message;
}
}
// Initial load
fetchStatus();
fetchEvents();
fetchConfig();
// Auto-refresh
setInterval(fetchStatus, 30000);
setInterval(fetchEvents, 30000);
</script>
</body>
</html>`;
}