commit 34858a308878dbd9aa7a874928fbfbfe345d89e6 Author: peptiderepo Date: Fri Jun 19 09:02:59 2026 +0000 feat: initial runner dashboard 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..36cddb7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +*.md +.env* +node_modules diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd06eec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY server.js . +COPY index.html . + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f967ca0 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# runner-dashboard + +Self-hosted GitHub Actions runner status dashboard for the peptide-repo org. + +## Setup + +Set the following env vars (via Coolify or `.env` locally): + +| Var | Purpose | +|-----|---------| +| `GITHUB_RUNNERS_READ_TOKEN` | Fine-grained PAT: org `peptide-repo`, Self-hosted runners: Read-only | +| `DASH_USER` | HTTP Basic Auth username | +| `DASH_PASS` | HTTP Basic Auth password | +| `PORT` | Port to listen on (default: 3000) | + +## Run locally + +```sh +DASH_USER=admin DASH_PASS=secret GITHUB_RUNNERS_READ_TOKEN=ghp_... node server.js +``` + +Then open http://localhost:3000 (auth required). + +## Health check + +`GET /healthz` returns `200 ok` — no auth, no GitHub call. Used by Coolify. diff --git a/index.html b/index.html new file mode 100644 index 0000000..ed432ce --- /dev/null +++ b/index.html @@ -0,0 +1,392 @@ + + + + + +Peptide Repo / CI Runners + + + + + + + +
+
+ Peptide Repo / CI Runners +
+
+
+ Loading… +
+
+ +
Connecting…
+
+ +
+
+
+ + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..7c51170 --- /dev/null +++ b/server.js @@ -0,0 +1,183 @@ +/** + * 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}`); +});