initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ic08e7c4b5b4f4072de9e2f9a701e977b6a6a6964
This commit is contained in:
commit
f8db097ba9
21 changed files with 4924 additions and 0 deletions
312
src/dashboard.ts
Normal file
312
src/dashboard.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
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());
|
||||
|
||||
// --- 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 Record<string, unknown>, partial as Record<string, 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>`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue