Consolidate MangaCircus app, scraper, and Compose baseline

This commit is contained in:
Maashkinoozhe committed 2026-10-07 23:28:20 +02:00
1 parent cfb2d79103
commit eef4ffa07c
103 files changed
+9316 -172

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs'), path = require('path'), http = require('http');
const { secret } = require('../runtime/config');
const { Monitor } = require('./monitor');
const root = path.resolve(process.env.SENTINEL_DATA || path.join(process.env.DATA || 'data', 'sentinel'));
fs.mkdirSync(root, { recursive: true });
const token = secret('SCRAPER_TOKEN');
const host = process.env.SENTINEL_HOST || '127.0.0.1', port = +(process.env.SENTINEL_PORT || 8092);
if (!['127.0.0.1', 'localhost', '::1'].includes(host) && !token) throw new Error('SCRAPER_TOKEN is required for non-loopback Sentinel');
const reader = process.env.READER_URL || 'http://127.0.0.1:8080';
const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091';
const file = path.join(root, 'state.json');
let saved = {};
if (fs.existsSync(file)) saved = JSON.parse(fs.readFileSync(file, 'utf8'));
const monitor = new Monitor({ incidents: saved.incidents || [] });
const request = async (base, endpoint, body) => {
const response = await fetch(base + endpoint, { headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
signal: AbortSignal.timeout(8000), ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
if (!response.ok) throw new Error(`Service HTTP ${response.status}`);
return response.json();
};
let stopped = false, timer, lastTick = Date.now();
async function tick() {
try {
const results = await Promise.allSettled([request(reader, '/health'), request(scraper, '/api/state')]);
const state = await monitor.observe(results[0].status === 'fulfilled' ? results[0].value : null,
results[1].status === 'fulfilled' ? results[1].value : null, input => request(scraper, '/api/recovery', input));
fs.writeFileSync(file + '.tmp', JSON.stringify(state, null, 2) + '\n'); fs.renameSync(file + '.tmp', file);
lastTick = Date.now();
for (const incident of state.incidents.filter(i => i.state === 'open' && !i.logged)) {
console.log(JSON.stringify({ ...incident, targetService: incident.service, service: 'sentinel', level: 'warning' })); incident.logged = true;
}
} catch (error) { console.error('[sentinel]', error.message); }
if (!stopped) timer = setTimeout(tick, 30000);
}
const server = http.createServer((req, res) => {
const endpoint = new URL(req.url, 'http://localhost').pathname;
let code = 200, value;
if (endpoint === '/health') { code = Date.now() - lastTick < 120000 ? 200 : 503; value = { ok: code === 200, service: 'sentinel', checkedAt: monitor.state.checkedAt }; }
else if (token && req.headers.authorization !== `Bearer ${token}`) { code = 401; value = { error: 'Unauthorized' }; }
else if (endpoint === '/api/state' && req.method === 'GET') value = monitor.state;
else if (endpoint === '/metrics' && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4' });
return res.end(`mangacircus_reader_up ${Number(!!monitor.state.services.reader)}\nmangacircus_scraper_up ${Number(!!monitor.state.services.scraper)}\nmangacircus_open_incidents ${monitor.incidents.filter(i => i.state === 'open').length}\n`);
} else { code = 404; value = { error: 'Not found' }; }
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
});
server.listen(port, host, () => console.log(JSON.stringify({ service: 'sentinel', event: 'started', port })));
tick();
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { stopped = true; clearTimeout(timer); server.close(() => process.exit(0)); });