Node 20 / no-framework HTTP service. Routes: /healthz (no auth), /api/runners (proxy to GitHub), / (dashboard UI). Dark theme, Basic Auth, 15s poll, stale-data strip, token-missing empty state. Agent-Session: runner-dashboard-deploy-2026-06-19
184 lines
5.3 KiB
JavaScript
184 lines
5.3 KiB
JavaScript
/**
|
|
* Runner Dashboard — HTTP server
|
|
*
|
|
* What: Serves the CI runner status dashboard for peptide-repo org.
|
|
* Who triggers: Node process (started by Docker CMD or local `node server.js`).
|
|
* Deps: Node 20 built-ins only (http, https, crypto, fs, path).
|
|
*
|
|
* Routes:
|
|
* GET /healthz → 200 "ok" (no auth, no GitHub call — for Coolify health check)
|
|
* GET /api/runners → proxied GitHub runners JSON (auth required)
|
|
* GET / → dashboard HTML (auth required)
|
|
*
|
|
* Env:
|
|
* PORT default 3000
|
|
* DASH_USER / DASH_PASS HTTP Basic Auth credentials
|
|
* GITHUB_RUNNERS_READ_TOKEN fine-grained PAT for org runners API
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
const DASH_USER = process.env.DASH_USER || '';
|
|
const DASH_PASS = process.env.DASH_PASS || '';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse and validate HTTP Basic Auth header.
|
|
* @param {http.IncomingMessage} req
|
|
* @returns {boolean}
|
|
*/
|
|
function isAuthorised(req) {
|
|
if (!DASH_USER || !DASH_PASS) return false;
|
|
const header = req.headers['authorization'] || '';
|
|
if (!header.startsWith('Basic ')) return false;
|
|
const [user, pass] = Buffer.from(header.slice(6), 'base64').toString().split(':');
|
|
return user === DASH_USER && pass === DASH_PASS;
|
|
}
|
|
|
|
/**
|
|
* Send a 401 Unauthorized response demanding Basic Auth.
|
|
* @param {http.ServerResponse} res
|
|
*/
|
|
function requireAuth(res) {
|
|
res.writeHead(401, {
|
|
'WWW-Authenticate': 'Basic realm="Runner Dashboard"',
|
|
'Content-Type': 'text/plain',
|
|
});
|
|
res.end('Unauthorized');
|
|
}
|
|
|
|
/**
|
|
* Fetch the GitHub org runners list server-side.
|
|
* Reads token from process.env at call time (never cached at module level).
|
|
* @returns {Promise<{status:number, body:string}>}
|
|
*/
|
|
function fetchRunners() {
|
|
return new Promise((resolve) => {
|
|
const token = process.env.GITHUB_RUNNERS_READ_TOKEN;
|
|
if (!token) {
|
|
resolve({ status: 200, body: JSON.stringify({ error: 'token_missing' }) });
|
|
return;
|
|
}
|
|
|
|
const options = {
|
|
hostname: 'api.github.com',
|
|
path: '/orgs/peptide-repo/actions/runners',
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Accept': 'application/vnd.github+json',
|
|
'User-Agent': 'runner-dashboard/1.0',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
},
|
|
};
|
|
|
|
const req = https.request(options, (ghRes) => {
|
|
let raw = '';
|
|
ghRes.on('data', (chunk) => { raw += chunk; });
|
|
ghRes.on('end', () => {
|
|
if (ghRes.statusCode !== 200) {
|
|
const body = JSON.stringify({ error: 'api_error', status: ghRes.statusCode });
|
|
resolve({ status: 200, body });
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
resolve({ status: 200, body: JSON.stringify(parsed.runners || parsed) });
|
|
} catch {
|
|
resolve({ status: 200, body: JSON.stringify({ error: 'api_error', status: ghRes.statusCode }) });
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', () => {
|
|
resolve({ status: 200, body: JSON.stringify({ error: 'api_error', status: 0 }) });
|
|
});
|
|
|
|
req.setTimeout(10000, () => {
|
|
req.destroy();
|
|
resolve({ status: 200, body: JSON.stringify({ error: 'api_error', status: 0 }) });
|
|
});
|
|
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Request handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Main HTTP request handler.
|
|
* @param {http.IncomingMessage} req
|
|
* @param {http.ServerResponse} res
|
|
*/
|
|
async function handler(req, res) {
|
|
const url = (req.url || '/').split('?')[0];
|
|
|
|
// Health check — no auth, no GitHub call
|
|
if (url === '/healthz') {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('ok');
|
|
return;
|
|
}
|
|
|
|
// All other routes require Basic Auth
|
|
if (!isAuthorised(req)) {
|
|
requireAuth(res);
|
|
return;
|
|
}
|
|
|
|
if (url === '/api/runners') {
|
|
const { status, body } = await fetchRunners();
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body);
|
|
return;
|
|
}
|
|
|
|
if (url === '/') {
|
|
const htmlPath = path.join(__dirname, 'index.html');
|
|
try {
|
|
const html = fs.readFileSync(htmlPath, 'utf8');
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
res.end(html);
|
|
} catch {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
res.end('index.html not found');
|
|
}
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('Not found');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Start server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const server = http.createServer((req, res) => {
|
|
handler(req, res).catch((err) => {
|
|
console.error('Unhandled error:', err);
|
|
if (!res.headersSent) {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
res.end('Internal server error');
|
|
}
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`Runner dashboard listening on port ${PORT}`);
|
|
});
|