React frontend: PR dashboard, per-check dropdowns, timeline, themes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
co-authored by
Claude Fable 5
parent
1163357967
commit
c9d810c8e5
@@ -0,0 +1,49 @@
|
||||
import { loadConfig } from './config.js';
|
||||
import { Db } from './db.js';
|
||||
import { runPoll } from './poller.js';
|
||||
import { getLastRateLimit } from './github/client.js';
|
||||
import { createApp, buildSyncPayload } from './routes.js';
|
||||
import { broadcast } from './sse.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = new Db();
|
||||
|
||||
let polling = false;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
try {
|
||||
const result = await runPoll(config, db);
|
||||
console.log(
|
||||
`[poll] ${result.prCount} PRs, ${result.changedPrIds.length} changed, ${result.newEventCount} new events`,
|
||||
);
|
||||
broadcast(buildSyncPayload(db, result.changedPrIds, result.newEventCount));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[poll] failed: ${message}`);
|
||||
db.setMeta('last_sync_error', message);
|
||||
broadcast(buildSyncPayload(db));
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext(): void {
|
||||
// Back off to 5 minutes when the rate-limit budget runs low.
|
||||
const remaining = getLastRateLimit()?.remaining;
|
||||
const intervalMs =
|
||||
remaining !== undefined && remaining < 500 ? 5 * 60_000 : config.pollIntervalSeconds * 1000;
|
||||
setTimeout(async () => {
|
||||
await poll();
|
||||
scheduleNext();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
const app = createApp({ db, triggerSync: poll });
|
||||
app.listen(config.port, () => {
|
||||
console.log(`pr-monitor listening on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
await poll();
|
||||
scheduleNext();
|
||||
@@ -0,0 +1,86 @@
|
||||
import express, { type Express } from 'express';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { StatePayload, SyncEventPayload } from '../shared/types.js';
|
||||
import type { Db } from './db.js';
|
||||
import { rootDir } from './config.js';
|
||||
import { addClient } from './sse.js';
|
||||
|
||||
export interface RouteDeps {
|
||||
db: Db;
|
||||
triggerSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function buildSyncPayload(db: Db, changedPrIds: string[] = [], newEventCount = 0): SyncEventPayload {
|
||||
return {
|
||||
lastSyncAt: db.getMeta('last_sync_at'),
|
||||
error: db.getMeta('last_sync_error'),
|
||||
changedPrIds,
|
||||
newEventCount,
|
||||
unreadCount: db.unreadCounts().total,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp({ db, triggerSync }: RouteDeps): Express {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/api/state', (_req, res) => {
|
||||
const { byPr, total } = db.unreadCounts();
|
||||
const payload: StatePayload = {
|
||||
prs: db.getOpenSnapshots(),
|
||||
lastSyncAt: db.getMeta('last_sync_at'),
|
||||
lastSyncError: db.getMeta('last_sync_error'),
|
||||
unreadCountsByPr: byPr,
|
||||
unreadTotal: total,
|
||||
};
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
app.get('/api/timeline', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
|
||||
const unreadOnly = req.query.unread === '1';
|
||||
res.json(db.timeline({ limit, before, unreadOnly }));
|
||||
});
|
||||
|
||||
app.post('/api/timeline/:id/read', (req, res) => {
|
||||
db.markRead(req.params.id, true, new Date().toISOString());
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/timeline/:id/unread', (req, res) => {
|
||||
db.markRead(req.params.id, false, new Date().toISOString());
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/timeline/read-all', (req, res) => {
|
||||
const before = typeof req.body?.before === 'string' ? req.body.before : undefined;
|
||||
const changed = db.markAllRead(new Date().toISOString(), before);
|
||||
res.json({ ok: true, changed });
|
||||
});
|
||||
|
||||
app.post('/api/prs/:id/read-all', (req, res) => {
|
||||
const changed = db.markAllReadForPr(req.params.id, new Date().toISOString());
|
||||
res.json({ ok: true, changed });
|
||||
});
|
||||
|
||||
app.post('/api/sync', (_req, res) => {
|
||||
void triggerSync();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/events', (_req, res) => {
|
||||
addClient(res, buildSyncPayload(db));
|
||||
});
|
||||
|
||||
const webDist = join(rootDir, 'web', 'dist');
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||
res.sendFile(join(webDist, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Response } from 'express';
|
||||
import type { SyncEventPayload } from '../shared/types.js';
|
||||
|
||||
const clients = new Set<Response>();
|
||||
|
||||
export function addClient(res: Response, initial: SyncEventPayload): void {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
res.write(frame(initial));
|
||||
clients.add(res);
|
||||
res.on('close', () => clients.delete(res));
|
||||
}
|
||||
|
||||
export function broadcast(payload: SyncEventPayload): void {
|
||||
const data = frame(payload);
|
||||
for (const res of clients) res.write(data);
|
||||
}
|
||||
|
||||
function frame(payload: SyncEventPayload): string {
|
||||
return `event: sync\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
for (const res of clients) res.write(': ping\n\n');
|
||||
}, 25_000).unref();
|
||||
Reference in New Issue
Block a user