initial commit

This commit is contained in:
2026-10-13 02:24:39 +03:00
commit 191b80b6b1
80 changed files with 6863 additions and 0 deletions

69
server/browser-checks.mjs Normal file
View File

@@ -0,0 +1,69 @@
import { existsSync } from 'node:fs';
import { chromium } from 'playwright';
export function createBrowserChecks(config) {
let browser = null, launching = null, active = 0;
const queue = [];
async function getBrowser() {
if (browser?.isConnected()) return browser;
if (!launching) {
const installedChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const executablePath = config.browser.executablePath || (process.platform === 'darwin' && existsSync(installedChrome) ? installedChrome : undefined);
launching = chromium.launch({ headless: true, chromiumSandbox: true, executablePath, timeout: 15000 }).then(value => { browser = value; return value; }).finally(() => { launching = null; });
}
return launching;
}
async function run(spec, target) {
if (active >= 2) await new Promise(resolve => queue.push(resolve)); else active++;
let context;
const started = performance.now();
try {
let currentBrowser;
try { currentBrowser = await getBrowser(); } catch { return { ok: null, reason: 'browser_unavailable', latencyMs: null }; }
context = await currentBrowser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'ru-RU', timezoneId: 'Europe/Minsk', reducedMotion: 'reduce', serviceWorkers: 'block' });
const origin = new URL(spec.baseUrl).origin;
if (spec.cookie) {
const cookies = spec.cookie.split(';').map(item => {
const separator = item.indexOf('=');
if (separator <= 0) return null;
return { name: item.slice(0, separator).trim(), value: item.slice(separator + 1).trim(), url: `${origin}/`, httpOnly: true, secure: origin.startsWith('https:'), sameSite: 'Strict' };
});
if (cookies.some(item => !item || !item.name || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(item.name))) return { ok: null, reason: 'auth_required', latencyMs: null };
// Chromium ignores a Cookie override in route.continue(). Load the
// existing test session into this fresh browser context instead.
await context.addCookies(cookies);
}
// Probes run in fresh contexts, with no offline cache or write requests.
await context.route('**/*', async route => {
const request = route.request();
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) { await route.abort(); return; }
if (spec.cookie && new URL(request.url()).origin !== origin) { await route.abort(); return; }
await route.continue();
});
if (spec.type === 'board') await context.addInitScript(({ stopId }) => { localStorage.setItem('ridebus-board-settings-v1', JSON.stringify({ stopId, demo: false, nightMode: 'off' })); }, target);
const page = await context.newPage();
page.setDefaultTimeout(config.scenarioTimeoutMs);
const path = spec.type === 'pwa' ? `/?city=${target.cityId}&stop=${target.stopId}` : '/';
const dataResponse = page.waitForResponse(response => {
const url = new URL(response.url());
return url.origin === new URL(spec.baseUrl).origin && (spec.type === 'pwa' ? url.pathname === '/api/data' && url.searchParams.get('resource') === 'departures' && url.searchParams.get('stopId') === String(target.stopId) : url.pathname === '/api/board' && url.searchParams.get('stopId') === String(target.stopId));
});
// Consume early rejections even if navigation itself fails.
void dataResponse.catch(() => {});
const navigation = await page.goto(new URL(path, spec.baseUrl).toString(), { waitUntil: 'domcontentloaded', timeout: config.scenarioTimeoutMs });
if (!navigation || navigation.status() !== 200) return { ok: false, reason: 'render', httpCode: navigation?.status() ?? null, latencyMs: null };
const response = await dataResponse;
if (!response.ok()) return { ok: [401, 403].includes(response.status()) ? null : false, reason: [401, 403].includes(response.status()) ? 'auth_required' : 'render', httpCode: response.status(), latencyMs: null };
if (spec.type === 'pwa') {
await page.waitForFunction(() => Boolean(document.querySelector('.boardCard .departureList')) || /Пока нет отправлений|Нет ближайших отправлений/.test(document.querySelector('.boardCard .boardEmpty h3')?.textContent || ''));
if (await page.locator('.boardCard .savedNotice, .boardCard .partialNotice').count()) return { ok: false, reason: 'render', httpCode: 200, latencyMs: null };
} else {
await page.waitForFunction(() => Boolean(document.querySelector('.board .scene-container')) && !document.querySelector('.offline-banner') && !document.querySelector('.device-setup'));
if (await page.locator('.board-header h1').count() !== 1) return { ok: false, reason: 'render', httpCode: 200, latencyMs: null };
}
return { ok: true, reason: null, httpCode: 200, latencyMs: Math.round(performance.now() - started) };
} catch { return { ok: false, reason: 'render', latencyMs: null }; }
finally { await context?.close().catch(() => {}); const next = queue.shift(); if (next) next(); else active--; }
}
return { run, async close() { const current = browser || await launching?.catch(() => null); await current?.close(); browser = null; } };
}

78
server/config.mjs Normal file
View File

@@ -0,0 +1,78 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createHash } from 'node:crypto';
function numberSetting(env, key, fallback, min, max) {
const value = Number(env[key] ?? fallback);
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${key} must be an integer between ${min} and ${max}`);
return value;
}
function checkedUrl(value) {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Service URLs must use HTTP(S), without embedded credentials');
return url.toString();
}
export function loadConfig(env = process.env) {
const file = resolve(env.STATUS_SERVICES_FILE || './config/services.json');
const definitions = JSON.parse(readFileSync(file, 'utf8'));
if (!Array.isArray(definitions) || !definitions.length || definitions.length > 30) throw new Error('Configure between 1 and 30 services');
const ids = new Set();
const services = definitions.map(definition => {
if (!/^[a-z][a-z0-9-]{0,39}$/.test(definition.id) || ids.has(definition.id)) throw new Error('Service IDs must be unique lowercase slugs');
ids.add(definition.id);
if (!definition.name?.ru || !definition.name?.en || !definition.description?.ru || !definition.description?.en) throw new Error(`Missing translations for ${definition.id}`);
if (!['html', 'json', 'http'].includes(definition.kind)) throw new Error(`Invalid check kind for ${definition.id}`);
const override = env[`STATUS_${definition.id.toUpperCase().replaceAll('-', '_')}_URL`];
const url = override === 'off' ? null : checkedUrl(override || definition.url);
const headers = {};
for (const [header, envKey] of Object.entries(definition.headerEnv || {})) {
if (env[envKey]) headers[header] = env[envKey];
}
const probes = url ? [{ url, kind: definition.kind, expectedJson: definition.expectedJson, headers }] : [];
if (definition.id === 'api' && url && env.RIDEBUS_API_KEY) {
probes.push({ url: checkedUrl(env.STATUS_API_DATA_URL || 'https://api.ridebus.by/api/v1/cities'), kind: 'json', headers: { 'X-API-Key': env.RIDEBUS_API_KEY }, requireDataArray: true });
}
// Hash the target and rules so a changed probe starts a separate history.
const signature = createHash('sha256').update(JSON.stringify(probes.map(({ headers: _headers, ...probe }) => probe))).digest('hex').slice(0, 24);
const publicUrl = checkedUrl(definition.publicUrl);
if (new URL(publicUrl).search || new URL(publicUrl).hash) throw new Error('Public links must not include query parameters or fragments');
if (definition.scenario && !['api', 'pwa', 'board'].includes(definition.scenario)) throw new Error('Unsupported scenario');
const scenario = definition.scenario && url ? {
type: definition.scenario,
baseUrl: checkedUrl(env[`STATUS_${definition.id.toUpperCase()}_SCENARIO_BASE_URL`] || new URL(url).origin),
apiKey: definition.scenario === 'api' ? env.RIDEBUS_API_KEY || null : null,
cookie: definition.scenario === 'board' ? env.STATUS_BOARD_TEST_COOKIE || null : null,
cityId: env.STATUS_TEST_CITY_ID ? numberSetting(env, 'STATUS_TEST_CITY_ID', 1, 1, 2147483647) : null,
stopId: env[`STATUS_${definition.id.toUpperCase()}_TEST_STOP_ID`] ? numberSetting(env, `STATUS_${definition.id.toUpperCase()}_TEST_STOP_ID`, 1, 1, 2147483647) : null,
} : null;
if (scenario) scenario.signature = createHash('sha256').update(JSON.stringify({ ...scenario, apiKey: Boolean(scenario.apiKey), cookie: Boolean(scenario.cookie), ...(scenario.type === 'board' ? { settingsSource: 'paired-device-v1' } : {}) })).digest('hex').slice(0, 24);
const impactPolicy = definition.impact || null;
if (impactPolicy) {
for (const key of ['availability', 'scenario', 'advice']) if (impactPolicy[key] && ['ru', 'en'].some(locale => typeof impactPolicy[key][locale] !== 'string' || !impactPolicy[key][locale].trim() || impactPolicy[key][locale].length > 2000)) throw new Error(`Invalid impact translations for ${definition.id}`);
if (impactPolicy.fallbacks && (!Array.isArray(impactPolicy.fallbacks) || impactPolicy.fallbacks.some(id => !definitions.some(item => item.id === id)))) throw new Error('Unknown fallback service');
}
return { id: definition.id, icon: definition.icon || definition.id, name: definition.name, description: definition.description, publicUrl, kind: definition.kind, functional: probes.length > 1 || Boolean(scenario), probes, signature, scenario, impactPolicy };
});
const intervalSeconds = numberSetting(env, 'STATUS_INTERVAL_SECONDS', 60, 10, 3600);
return {
services, intervalSeconds, staleAfterSeconds: intervalSeconds * 3, publicUrl: env.STATUS_PUBLIC_URL ? checkedUrl(env.STATUS_PUBLIC_URL) : null,
timeoutMs: numberSetting(env, 'STATUS_TIMEOUT_MS', 5000, 100, 30000),
slowMs: numberSetting(env, 'STATUS_SLOW_MS', 1500, 50, 30000),
failureThreshold: numberSetting(env, 'STATUS_FAILURE_THRESHOLD', 3, 1, 10),
recoveryThreshold: numberSetting(env, 'STATUS_RECOVERY_THRESHOLD', 2, 1, 10),
retentionDays: numberSetting(env, 'STATUS_RETENTION_DAYS', 90, 30, 365),
scenarioIntervalSeconds: numberSetting(env, 'STATUS_SCENARIO_INTERVAL_SECONDS', 300, 10, 3600),
scenarioTimeoutMs: numberSetting(env, 'STATUS_SCENARIO_TIMEOUT_MS', 20000, 1000, 60000),
scenarioSlowMs: numberSetting(env, 'STATUS_SCENARIO_SLOW_MS', 30000, 1000, 120000),
browser: { enabled: !['off', 'false', '0'].includes(env.STATUS_BROWSER_CHECKS || ''), executablePath: env.STATUS_BROWSER_EXECUTABLE_PATH || null },
telegram: {
enabled: Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID), token: env.TELEGRAM_BOT_TOKEN || '', chatId: env.TELEGRAM_CHAT_ID || '',
locale: env.STATUS_NOTIFY_LANG === 'en' ? 'en' : 'ru', publicUrl: env.STATUS_PUBLIC_URL ? checkedUrl(env.STATUS_PUBLIC_URL) : null,
timezone: env.STATUS_TIMEZONE || 'Europe/Minsk',
},
dbPath: env.STATUS_DB_PATH || './data/status.sqlite',
port: numberSetting(env, 'PORT', 5188, 0, 65535), host: env.HOST || '0.0.0.0',
};
}

73
server/demo.mjs Normal file
View File

@@ -0,0 +1,73 @@
import { loadConfig } from './config.mjs';
import { incidentImpact, serviceImpact } from '../shared/impact.mjs';
// Deterministic illustrative data, isolated from monitoring and live storage.
export function demoSnapshot(range, now = Date.now()) {
const config = loadConfig({});
const dayMs = 86400000;
const today = Math.floor(now / dayMs) * dayMs;
const services = config.services.map((service, serviceIndex) => {
const history = Array.from({ length: range }, (_, index) => {
const start = today - (range - index - 1) * dayMs;
const failed = service.id === 'board' && start === today - 8 * dayMs;
return { date: new Date(start).toISOString().slice(0, 10), status: failed ? 'down' : 'operational', uptime: failed ? 95.83 : 100, checks: Math.max(1, Math.floor((Math.min(now, start + dayMs) - start) / 60000)), latencyMs: 110 + serviceIndex * 40, coverage: 100 };
});
const responseHistory = Array.from({ length: 24 }, (_, index) => ({ time: now - (24 - index) * 3600000, checks: 60, uptime: 100, latencyMs: 80 + serviceIndex * 42 + Math.round((1 + Math.sin(index / 2 + serviceIndex)) * 65) }));
const checks = history.reduce((sum, item) => sum + item.checks, 0);
const uptime = Number((history.reduce((sum, item) => sum + item.checks * item.uptime, 0) / checks).toFixed(2));
return {
id: service.id, icon: service.icon, name: service.name, description: service.description,
publicUrl: service.publicUrl, kind: service.kind, functional: false, impactPolicy: service.impactPolicy,
configured: true, status: service.id === 'board' ? 'maintenance' : service.id === 'pwa' ? 'degraded' : 'operational', availabilityStatus: 'operational', activeMaintenance: service.id === 'board' ? ['demo-maintenance-active'] : [],
scenario: service.scenario ? { type: service.scenario.type, status: 'operational', lastCheckedAt: now, staleAfterSeconds: 900, intervalSeconds: 300, stale: false, reason: null, mode: service.id === 'api' ? 'http' : 'browser', pending: false, steps: (service.id === 'board' ? ['config', 'stops', 'departures', 'render'] : service.id === 'api' ? ['cities', 'stops', 'departures'] : ['cities', 'stops', 'departures', 'render']).map(id => ({ id, status: 'passed', latencyMs: 120, httpCode: 200 })) } : null,
stale: false, pending: false,
lastCheckedAt: now, latencyMs: 110 + serviceIndex * 40, avgLatencyMs: 110 + serviceIndex * 40,
httpCode: 200, reason: null, uptime, checks, history, responseHistory,
recentChecks: Array.from({ length: 12 }, (_, index) => ({ time: now - index * 60000, ok: true, latencyMs: 110 + serviceIndex * 40 + index * 2, httpCode: 200, reason: null })),
};
});
const pwa = services.find(service => service.id === 'pwa');
pwa.scenario.status = 'down'; pwa.scenario.reason = 'content';
pwa.scenario.steps = pwa.scenario.steps.map(step => ({ ...step, status: step.id === 'departures' ? 'failed' : step.id === 'render' ? 'waiting' : 'passed' }));
services.forEach(service => { service.impact = serviceImpact(service, services); });
services.forEach((service, index) => {
const since = new Date(service.history[0].date).getTime();
const boardRecovery = today - 8 * dayMs + 12 * 3600000;
const duration = service.id === 'pwa' ? 35 * 60000 : service.id === 'board' && range > 8 ? 3600000 : 0;
service.reliability = { downtimeMs: duration, longestDowntimeMs: duration, incidentCount: Number(duration > 0), downtimeIncomplete: false, lastRecoveryAt: service.id === 'board' ? boardRecovery : null, lastRecoveryScope: service.id === 'board' ? 'availability' : null, observedMs: now - since, coverage: 100, since, asOf: now };
service.latencyHistory = service.history.map((day, i) => ({ time: new Date(day.date).getTime(), checks: day.checks, uptime: day.uptime, latencyMs: Math.round(day.latencyMs * (1 + Math.sin(i / 4 + index) * .18)) }));
const stats = (points, change) => {
const samples = points.reduce((sum, point) => sum + point.checks, 0);
const averageMs = Math.round(points.reduce((sum, point) => sum + point.latencyMs * point.checks, 0) / Math.max(1, samples));
return { averageMs, p95Ms: Math.round(Math.max(...points.map(point => point.latencyMs)) * 1.1), samples, previousAverageMs: Math.round(averageMs / (1 + change / 100)), changePercent: change };
};
service.latency = stats(service.latencyHistory, index % 2 ? 12.5 : -8.2);
service.latency24h = stats(service.responseHistory, index % 2 ? 7.1 : -4.6);
});
const active = {
id: 'example-active', source: 'monitor', scope: 'scenario', serviceId: 'pwa', startedAt: now - 35 * 60000, resolvedAt: null,
title: { ru: 'Расписание в приложении загружается с перебоями', en: 'Timetable loading disrupted in the app' },
message: { ru: 'Пример инцидента: проверка расписания не проходит.', en: 'Sample incident: the timetable check fails.' },
updates: [
{ id: 'demo-active-1', source: 'monitor', stage: 'detected', createdAt: now - 35 * 60000, message: { ru: 'Пример: повторные проверки подтвердили проблему с загрузкой расписания.', en: 'Sample: repeated checks confirmed a timetable loading problem.' } },
{ id: 'demo-active-2', source: 'manual', stage: 'investigating', createdAt: now - 25 * 60000, message: { ru: 'Пример: команда проверяет загрузку отправлений для остановок.', en: 'Sample: the team is investigating stop departure loading.' } },
{ id: 'demo-active-3', source: 'manual', stage: 'identified', createdAt: now - 10 * 60000, message: { ru: 'Пример: найдена причина задержек. Готовим исправление; сохранённое расписание может быть доступно.', en: 'Sample: the cause of delays has been identified. A fix is being prepared; saved timetables may be available.' } },
],
};
active.impact = incidentImpact(active, services);
return {
generatedAt: now, lastCheckAt: now, intervalSeconds: config.intervalSeconds, staleAfterSeconds: config.staleAfterSeconds,
failureThreshold: config.failureThreshold, recoveryThreshold: config.recoveryThreshold, slowMs: config.slowMs,
scenarioIntervalSeconds: 300, range, demo: true, overall: 'degraded', services,
incidents: [active, { id: 'example-1', source: 'monitor', scope: 'availability', serviceId: 'board', startedAt: today - 8 * dayMs + 11 * 3600000, resolvedAt: today - 8 * dayMs + 12 * 3600000, title: { ru: 'Задержки в работе электронного табло', en: 'Departure board delays' }, message: { ru: 'Пример инцидента: табло временно не получало данные.', en: 'Sample incident: the board temporarily could not retrieve data.' }, impact: config.services.find(service => service.id === 'board').impactPolicy.availability, updates: [
{ id: 'demo-closed-1', source: 'monitor', stage: 'detected', createdAt: today - 8 * dayMs + 11 * 3600000, message: { ru: 'Пример: проверки подтвердили задержки обновления табло.', en: 'Sample: checks confirmed delays in board updates.' } },
{ id: 'demo-closed-2', source: 'manual', stage: 'monitoring', createdAt: today - 8 * dayMs + 11.75 * 3600000, message: { ru: 'Пример: исправление применено, наблюдаем за обновлением расписания.', en: 'Sample: a fix has been applied; watching timetable updates.' } },
{ id: 'demo-closed-3', source: 'monitor', stage: 'resolved', createdAt: today - 8 * dayMs + 12 * 3600000, message: { ru: 'Пример: проверки подтвердили восстановление обновления данных.', en: 'Sample: checks confirmed that data updates were restored.' } },
{ id: 'demo-closed-4', source: 'manual', stage: 'postmortem', createdAt: today - 8 * dayMs + 12 * 3600000 + 10 * 60000, message: { ru: 'Пример разбора: причиной были задержки соединения с источником расписания. Добавили проверку времени ожидания, чтобы раньше замечать повторение проблемы.', en: 'Sample review: the cause was a delayed connection to the timetable source. A timeout check was added to detect recurrence sooner.' } },
] }],
maintenance: [
{ id: 'demo-maintenance-active', title: { ru: 'Обновляем электронное табло', en: 'Updating the departure board' }, message: { ru: 'Пример плановых работ: устанавливаем обновление. Возможны короткие перерывы в отображении расписания.', en: 'Sample maintenance: installing an update. Brief interruptions to timetable display are expected.' }, serviceIds: ['board'], startsAt: now - 15 * 60000, endsAt: now + 45 * 60000, createdAt: now - 86400000, cancelledAt: null, completedAt: null, phase: 'active' },
{ id: 'demo-maintenance-scheduled', title: { ru: 'Плановое обслуживание API', en: 'Scheduled API maintenance' }, message: { ru: 'Пример анонса: обновление транспортных сервисов. Расписание может быть временно недоступно.', en: 'Sample announcement: a transport service update. Timetables may be temporarily unavailable.' }, serviceIds: ['api', 'pwa'], startsAt: now + 86400000, endsAt: now + 86400000 + 30 * 60000, createdAt: now, cancelledAt: null, completedAt: null, phase: 'scheduled' },
],
};
}

55
server/feed.mjs Normal file
View File

@@ -0,0 +1,55 @@
import { stageNames } from './incident-updates.mjs';
import { maintenanceEnd } from './maintenance.mjs';
export function xmlEscape(value) {
return String(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '').replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char]);
}
export function feedOptions(params, services) {
if (Array.from(params.keys()).some(key => !['lang', 'services', 'types'].includes(key)) || ['lang', 'services', 'types'].some(key => params.getAll(key).length > 1)) throw new Error('Use lang, services and types only');
const locale = params.has('lang') ? params.get('lang') : 'ru';
if (!['ru', 'en'].includes(locale)) throw new Error('Language must be ru or en');
function list(key, allowed) {
const values = params.has(key) ? params.get(key).split(',') : allowed;
if (!values.length || values.some(value => !allowed.includes(value))) throw new Error(`Invalid ${key}`);
return [...new Set(values)].sort();
}
return { locale, serviceIds: list('services', services.map(service => service.id)), types: list('types', ['incidents', 'maintenance']) };
}
export function buildFeed(snapshot, options, publicUrl) {
const { locale, serviceIds, types } = options;
const ru = locale === 'ru';
const events = [];
const link = (view, id) => { const url = new URL('/', publicUrl); url.searchParams.set('view', view); url.searchParams.set('lang', locale); if (id) url.hash = id; return url.toString(); };
if (types.includes('incidents')) for (const incident of snapshot.incidents) {
if (incident.serviceId && !serviceIds.includes(incident.serviceId)) continue;
for (const update of incident.updates) {
const details = [update.source === 'monitor' ? (ru ? 'Автоматическая проверка.' : 'Automatic monitoring.') : (ru ? 'Сообщение команды.' : 'Team update.'), update.message[locale], incident.impact ? `${ru ? 'Влияние' : 'Impact'}: ${incident.impact[locale]}` : null].filter(Boolean).join('\n\n');
events.push({ id: `incident:${incident.id}:${incident.startedAt}:${update.id}`, time: update.createdAt, title: `${incident.title[locale]} — ${stageNames[update.stage][locale]}`, description: details, category: ru ? 'Инциденты' : 'Incidents', link: link('incidents', `incident-${incident.id}`) });
}
}
if (types.includes('maintenance')) for (const item of snapshot.maintenance) {
if (item.serviceIds.length && !item.serviceIds.some(id => serviceIds.includes(id))) continue;
const dates = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' });
const affected = item.serviceIds.length ? snapshot.services.filter(service => item.serviceIds.includes(service.id)).map(service => service.name[locale]).join(' · ') : (ru ? 'Все сервисы' : 'All services');
const description = `${item.message[locale]}\n\n${ru ? 'Плановое окно' : 'Scheduled window'}: ${dates.format(item.startsAt)} — ${dates.format(item.endsAt)} (UTC).\n${ru ? 'Затронутые сервисы' : 'Affected services'}: ${affected}.`;
function event(phase, time, title) {
if (time !== null && time <= snapshot.generatedAt) events.push({ id: `maintenance:${item.id}:${item.createdAt}:${phase}`, time, title: `${item.title[locale]} — ${title}`, description, category: ru ? 'Плановые работы' : 'Maintenance', link: link('maintenance', `maintenance-${item.id}`) });
}
event('announced', item.createdAt, ru ? 'Анонс работ' : 'Maintenance announced');
if (maintenanceEnd(item) > item.startsAt) event('started', item.startsAt, ru ? 'Плановое окно началось' : 'Scheduled window started');
if (maintenanceEnd(item) >= item.endsAt) event('ended', item.endsAt, ru ? 'Плановое окно закончилось' : 'Scheduled window ended');
event('cancelled', item.cancelledAt, ru ? 'Работы отменены' : 'Maintenance cancelled');
event('completed', item.completedAt, ru ? 'Завершено командой' : 'Completed by the team');
}
const visible = events.filter(event => event.time <= snapshot.generatedAt).sort((a, b) => b.time - a.time || b.id.localeCompare(a.id)).slice(0, 100);
const self = new URL('/feed.xml', publicUrl);
self.searchParams.set('lang', locale);
self.searchParams.set('services', serviceIds.join(','));
self.searchParams.set('types', types.join(','));
const tag = (name, value) => `<${name}>${xmlEscape(value)}</${name}>`;
// Descriptions are HTML in RSS readers: escape user text before XML encoding.
const descriptionHtml = value => value.split('\n\n').map(text => `<p>${xmlEscape(text).replaceAll('\n', '<br/>')}</p>`).join('');
const items = visible.map(event => `<item>${tag('title', event.title)}${tag('link', event.link)}<guid isPermaLink="false">${xmlEscape(event.id)}</guid>${tag('pubDate', new Date(event.time).toUTCString())}${tag('description', descriptionHtml(event.description))}${tag('category', event.category)}</item>`).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>${tag('title', ru ? 'RideBus — обновления сервисов' : 'RideBus — service updates')}${tag('link', link('incidents'))}${tag('description', ru ? 'Инциденты и плановые работы выбранных сервисов RideBus.' : 'Incidents and maintenance for your selected RideBus services.')}${tag('language', locale)}${tag('lastBuildDate', new Date(visible[0]?.time || snapshot.generatedAt).toUTCString())}<ttl>5</ttl><atom:link href="${xmlEscape(self.toString())}" rel="self" type="application/rss+xml"/>\n${items}\n</channel></rss>`;
}

View File

@@ -0,0 +1,17 @@
export const incidentStages = ['detected', 'reported', 'investigating', 'identified', 'monitoring', 'resolved', 'postmortem'];
export const teamStages = ['investigating', 'identified', 'monitoring', 'postmortem'];
export const stageNames = {
detected: { ru: 'Сбой обнаружен', en: 'Outage detected' },
reported: { ru: 'Сообщение команды', en: 'Team report' },
investigating: { ru: 'Проверяем причину', en: 'Investigating' },
identified: { ru: 'Причина найдена', en: 'Cause identified' },
monitoring: { ru: 'Наблюдаем за работой', en: 'Monitoring' },
resolved: { ru: 'Восстановлено', en: 'Resolved' },
postmortem: { ru: 'Причина и итоги', en: 'Cause and outcome' },
};
export function validateUpdate({ stage, messageRu, messageEn }) {
if (!teamStages.includes(stage)) throw new Error('Stage must be investigating, identified, monitoring or postmortem');
for (const text of [messageRu, messageEn]) if (typeof text !== 'string' || !text.trim() || text.trim().length > 2000) throw new Error('Provide message-ru and message-en, 1–2000 characters each');
return { stage, messageRu: messageRu.trim(), messageEn: messageEn.trim() };
}

40
server/incidents-cli.mjs Normal file
View File

@@ -0,0 +1,40 @@
import { loadConfig } from './config.mjs';
import { openStore } from './store.mjs';
const config = loadConfig();
const args = process.argv.slice(2);
function optionsFrom(items) {
const result = {};
for (const item of items) {
const at = item.indexOf('=');
if (at <= 0 || Object.hasOwn(result, item.slice(0, at))) throw new Error('Use unique key=value options');
result[item.slice(0, at)] = item.slice(at + 1);
}
return result;
}
const store = openStore(config.dbPath);
try {
if (args[0] === 'list') {
console.log(JSON.stringify(store.incidents().map(({ signature: _signature, ...incident }) => incident), null, 2));
} else if (args[0] === 'resolve' && /^\d+$/.test(args[1] || '')) {
const options = optionsFrom(args.slice(2));
const input = Object.keys(options).length ? { messageRu: options['message-ru'], messageEn: options['message-en'] } : null;
if (!store.resolveIncident(args[1], Date.now(), input)) throw new Error('Open manual incident not found; monitor incidents close after recovery');
console.log(`Resolved incident ${args[1]}`);
} else if (args[0] === 'update' && /^\d+$/.test(args[1] || '')) {
const options = optionsFrom(args.slice(2));
store.updateIncident(args[1], { stage: options.stage, messageRu: options['message-ru'], messageEn: options['message-en'] });
console.log(`Updated incident ${args[1]}`);
} else if (args[0] === 'create') {
const options = optionsFrom(args.slice(1));
const required = ['title-ru', 'title-en', 'message-ru', 'message-en'];
if (required.some(key => !options[key]?.trim() || options[key].length > 2000)) throw new Error('Provide title-ru, title-en, message-ru, message-en, 1–2000 characters');
if (options.service && !config.services.some(service => service.id === options.service)) throw new Error('Unknown service');
const id = store.createIncident({ serviceId: options.service || null, titleRu: options['title-ru'], titleEn: options['title-en'], messageRu: options['message-ru'], messageEn: options['message-en'] });
console.log(`Created incident ${id}`);
} else {
console.log('Usage: npm run incident -- list | resolve ID ["message-ru=..." "message-en=..."] | update ID stage=investigating|identified|monitoring|postmortem "message-ru=..." "message-en=..." | create service=api "title-ru=..." "title-en=..." "message-ru=..." "message-en=..."');
process.exitCode = 1;
}
} catch (error) { console.error(error.message); process.exitCode = 1; }
finally { store.close(); }

96
server/index.mjs Normal file
View File

@@ -0,0 +1,96 @@
import { createServer } from 'node:http';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './config.mjs';
import { openStore } from './store.mjs';
import { createMonitor } from './monitor.mjs';
import { makeSnapshot } from './snapshot.mjs';
import { demoSnapshot } from './demo.mjs';
import { createScenarioRunner } from './scenarios.mjs';
import { createNotifier } from './notifications.mjs';
import { buildFeed, feedOptions } from './feed.mjs';
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.png': 'image/png', '.json': 'application/json' };
export async function createStatusServer(config, { dev = false, monitorEnabled = true } = {}) {
const store = openStore(config.dbPath);
const scenarios = createScenarioRunner(config);
const monitor = createMonitor(store, config, { runScenario: service => scenarios.run(service) });
const notifier = createNotifier(store, config);
const vite = dev ? await (await import('vite')).createServer({ server: { middlewareMode: true }, appType: 'spa' }) : null;
const root = resolve('dist');
const demoCache = new Map();
const snapshotCache = new Map();
function liveSnapshot(range) {
const now = Date.now(), revision = store.dataRevision(), cached = snapshotCache.get(range);
if (cached && cached.revision === revision && now >= cached.snapshot.generatedAt && now - cached.snapshot.generatedAt < 5000 && Math.floor(now / 86400000) === Math.floor(cached.snapshot.generatedAt / 86400000)) return cached.snapshot;
const snapshot = makeSnapshot(store, config, range, now);
snapshotCache.set(range, { snapshot, revision });
return snapshot;
}
function json(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
res.end(JSON.stringify(data));
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://localhost');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!['GET', 'HEAD'].includes(req.method)) { res.setHeader('Allow', 'GET, HEAD'); json(res, 405, { error: 'Method not allowed' }); return; }
if (url.pathname === '/healthz') {
// This is the status application's health, independent of target outages.
json(res, 200, { status: 'ok' }); return;
}
if (url.pathname === '/api/status') {
const range = Number(url.searchParams.get('range') || 30);
if (![7, 30, 90].includes(range)) { json(res, 400, { error: 'Range must be 7, 30, or 90 days' }); return; }
if (url.searchParams.get('demo') === '1') {
const cached = demoCache.get(range);
if (!cached || Date.now() - cached.generatedAt > 30000) demoCache.set(range, demoSnapshot(range));
json(res, 200, demoCache.get(range));
} else json(res, 200, liveSnapshot(range));
return;
}
if (url.pathname === '/feed.xml') {
let options;
try { options = feedOptions(url.searchParams, config.services); } catch (error) { json(res, 400, { error: error.message }); return; }
const host = new URL(`http://${req.headers.host || 'localhost'}`);
const local = ['localhost', '127.0.0.1', '[::1]'].includes(host.hostname);
const publicUrl = config.publicUrl || `${local ? 'http' : 'https'}://${host.host}`;
const xml = buildFeed(liveSnapshot(7), options, publicUrl);
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Disposition': 'inline' });
res.end(req.method === 'HEAD' ? undefined : xml);
return;
}
if (url.pathname.startsWith('/api/')) { json(res, 404, { error: 'Not found' }); return; }
if (vite) { vite.middlewares(req, res); return; }
let decoded;
try { decoded = decodeURIComponent(url.pathname); } catch { json(res, 400, { error: 'Invalid path' }); return; }
const path = resolve(root, `.${decoded}`);
if (path !== root && !path.startsWith(root + sep)) { json(res, 404, { error: 'Not found' }); return; }
const file = decoded === '/' ? resolve(root, 'index.html') : path;
if (!existsSync(file) || !statSync(file).isFile()) { json(res, 404, { error: 'Not found' }); return; }
res.setHeader('Content-Type', MIME[extname(file)] || 'application/octet-stream');
res.setHeader('Cache-Control', decoded.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache');
if (req.method === 'HEAD') { res.end(); return; }
createReadStream(file).on('error', () => res.destroy()).pipe(res);
} catch { if (!res.headersSent) json(res, 500, { error: 'Status is temporarily unavailable' }); else res.destroy(); }
});
if (monitorEnabled) { monitor.start(); notifier.start(); }
return {
server, store, monitor,
async close() { await monitor.stop(); await notifier.stop(); await scenarios.close(); await vite?.close(); server.closeIdleConnections(); if (server.listening) await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); store.close(); },
};
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
const config = loadConfig();
const app = await createStatusServer(config, { dev: process.argv.includes('--dev') });
app.server.listen(config.port, config.host, () => {
console.log(`RideBus Status: http://localhost:${app.server.address().port}`);
console.log(`Monitoring ${config.services.length} services every ${config.intervalSeconds}s`);
});
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, async () => { await app.close(); process.exit(0); });
}

View File

@@ -0,0 +1,23 @@
import { loadConfig } from './config.mjs';
import { openStore } from './store.mjs';
import { parseInstant, validateMaintenance, maintenancePhase } from './maintenance.mjs';
const config = loadConfig();
const [command, ...args] = process.argv.slice(2);
const store = openStore(config.dbPath);
try {
if (command === 'list') console.log(JSON.stringify(store.maintenances().map(item => ({ ...item, phase: maintenancePhase(item) })), null, 2));
else if (['cancel', 'finish'].includes(command) && /^\d+$/.test(args[0] || '')) {
if (!store.endMaintenance(args[0], command)) throw new Error('Maintenance window not found or already closed');
console.log(`Maintenance ${args[0]}: ${command}`);
} else if (command === 'schedule') {
const options = Object.fromEntries(args.map(arg => { const at = arg.indexOf('='); if (at < 1) throw new Error('Use key=value arguments'); return [arg.slice(0, at), arg.slice(at + 1)]; }));
const input = validateMaintenance({
titleRu: options['title-ru'], titleEn: options['title-en'], messageRu: options['message-ru'], messageEn: options['message-en'],
serviceIds: options.services ? options.services.split(',') : [],
startsAt: parseInstant(options.starts), endsAt: parseInstant(options.ends),
}, config.services.map(service => service.id));
console.log(`Scheduled maintenance ${store.createMaintenance(input)}`);
} else throw new Error('Usage: npm run maintenance -- list | cancel ID | finish ID | schedule services=api,pwa starts=ISO ends=ISO "title-ru=..." "title-en=..." "message-ru=..." "message-en=..."');
} catch (error) { console.error(error.message); process.exitCode = 1; }
finally { store.close(); }

37
server/maintenance.mjs Normal file
View File

@@ -0,0 +1,37 @@
export function maintenanceEnd(item) {
return Math.min(item.endsAt, item.cancelledAt ?? Infinity, item.completedAt ?? Infinity);
}
export function maintenancePhase(item, now = Date.now()) {
if (item.cancelledAt !== null && item.cancelledAt <= now) return 'cancelled';
if (item.completedAt !== null && item.completedAt <= now) return 'completed';
if (now >= item.endsAt) return 'ended';
return now < item.startsAt ? 'scheduled' : 'active';
}
export function affectsService(item, serviceId, time = Date.now()) {
return (!item.serviceIds.length || item.serviceIds.includes(serviceId)) && item.startsAt <= time && time < maintenanceEnd(item);
}
export function validateMaintenance(input, serviceIds) {
for (const key of ['titleRu', 'titleEn', 'messageRu', 'messageEn']) {
if (typeof input[key] !== 'string' || !input[key].trim() || input[key].length > 2000) throw new Error(`Provide ${key} (1–2000 characters)`);
}
if (!Number.isSafeInteger(input.startsAt) || !Number.isSafeInteger(input.endsAt) || input.endsAt <= input.startsAt) throw new Error('End must be after start');
if (!Array.isArray(input.serviceIds) || input.serviceIds.some(id => !serviceIds.includes(id)) || new Set(input.serviceIds).size !== input.serviceIds.length) throw new Error('Unknown or duplicate service');
return input;
}
export function parseInstant(value) {
const valid = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value));
if (!valid || new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) !== value.slice(0, 10)) throw new Error('Use a valid ISO timestamp with an explicit timezone, for example 2026-10-13T02:00:00+03:00');
return Date.parse(value);
}
export function combinedStatus(availability, scenario, maintenance = false) {
if (maintenance) return 'maintenance';
if (availability === 'down' || availability === 'unknown') return availability;
if (scenario && ['down', 'degraded'].includes(scenario.status)) return 'degraded';
if (scenario?.status === 'unknown') return 'unknown';
return availability;
}

60
server/monitor.mjs Normal file
View File

@@ -0,0 +1,60 @@
import { checkService, nextState } from './probe.mjs';
export function createMonitor(store, config, { check = checkService, runScenario = null, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
let active = null;
let stopped = true;
let timer;
let lastPruned = 0;
let scenarioActive = null, scenarioTimer;
function previousState(service, scope, current) {
// Expected failures during maintenance do not count toward a new outage.
if (current && store.inMaintenance(service, current.checked_at) && !store.inMaintenance(service, now()) && !store.hasOpenIncident(service, scope)) return null;
return current;
}
async function cycle() {
if (active) return active;
active = (async () => {
await Promise.all(config.services.map(async service => {
const result = await check(service, config);
store.record(service, result, nextState(previousState(service, 'availability', store.state(service)), result, config), now(), { notify: config.telegram?.enabled });
}));
if (now() - lastPruned > 86400000) { store.prune(now() - config.retentionDays * 86400000); lastPruned = now(); }
})();
try { await active; } finally { active = null; }
}
async function scenarioCycle() {
if (!runScenario) return;
if (scenarioActive) return scenarioActive;
scenarioActive = (async () => {
await Promise.all(config.services.filter(service => service.scenario).map(async service => {
const previous = store.scenario(service);
if (previous && now() - previous.checked_at < config.scenarioIntervalSeconds * 1000) return;
const result = await runScenario(service);
store.recordScenario(service, result, nextState(previousState(service, 'scenario', previous), result, { ...config, slowMs: config.scenarioSlowMs }), now(), { notify: config.telegram?.enabled });
}));
})();
try { await scenarioActive; } finally { scenarioActive = null; }
}
async function loop() {
try { await cycle(); } catch { console.error('Monitoring cycle failed; previous results will expire.'); }
if (!stopped) timer = setTimer(loop, config.intervalSeconds * 1000);
}
async function scenarioLoop() {
const interval = config.scenarioIntervalSeconds * 1000;
let delay = interval;
try {
await scenarioCycle();
// Resume each saved deadline after a restart, even when a check is not due yet.
for (const service of config.services.filter(service => service.scenario)) {
const saved = store.scenario(service);
if (saved) delay = Math.min(delay, Math.max(100, saved.checked_at + interval - now()));
}
} catch { console.error('User journey checks failed; previous results will expire.'); }
if (!stopped) scenarioTimer = setTimer(scenarioLoop, delay);
}
return {
cycle, scenarioCycle,
start() { if (!stopped) return; stopped = false; void loop(); if (runScenario) void scenarioLoop(); },
async stop() { stopped = true; clearTimer(timer); clearTimer(scenarioTimer); await Promise.all([active, scenarioActive].filter(Boolean)); },
};
}

63
server/notifications.mjs Normal file
View File

@@ -0,0 +1,63 @@
export function notificationText(event, service, config) {
const en = config.locale === 'en';
const name = service.name[en ? 'en' : 'ru'];
const scope = event.scope === 'scenario' ? (en ? 'User journey' : 'Пользовательский сценарий') : (en ? 'Availability' : 'Доступность');
const recovered = event.kind === 'recovery';
const title = recovered ? (en ? 'Recovered' : 'Восстановлено') : (en ? 'Outage confirmed' : 'Сбой подтверждён');
const date = new Intl.DateTimeFormat(en ? 'en' : 'ru', { dateStyle: 'short', timeStyle: 'short', timeZone: config.timezone }).format(event.created_at);
const duration = recovered && event.resolved_at ? Math.max(1, Math.round((event.resolved_at - event.started_at) / 60000)) : null;
return [`RideBus Status · ${title}`, name, scope, date,
...(duration === null ? [] : [`${en ? 'Confirmed incident duration' : 'Длительность подтверждённого инцидента'}: ${duration} ${en ? 'min' : 'мин'}`]),
...(config.publicUrl ? [config.publicUrl] : []),
].join('\n').slice(0, 3500);
}
export async function sendTelegram(config, text, { fetchImpl = fetch } = {}) {
try {
const response = await fetchImpl(`https://api.telegram.org/bot${config.token}/sendMessage`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, redirect: 'error',
body: JSON.stringify({ chat_id: config.chatId, text, link_preview_options: { is_disabled: true } }),
signal: AbortSignal.timeout(5000),
});
let body;
try { body = await response.json(); } catch { body = null; }
if (response.ok && body?.ok === true) return { ok: true };
const code = body?.error_code || response.status;
const retryAfter = Number(body?.parameters?.retry_after);
return { ok: false, retryable: code === 429 || code >= 500, reason: code === 429 ? 'rate_limited' : code === 401 || code === 403 ? 'access_denied' : 'telegram_error', retryAfterSeconds: code === 429 && Number.isFinite(retryAfter) ? Math.max(1, Math.min(3600, retryAfter)) : null };
} catch { return { ok: false, retryable: true, reason: 'network', retryAfterSeconds: null }; }
}
export function createNotifier(store, config, { send = sendTelegram, now = Date.now } = {}) {
let active = null, stopped = true, timer;
async function flush() {
if (!config.telegram.enabled) return;
if (active) return active;
active = (async () => {
for (const event of store.dueNotifications(now())) {
const service = config.services.find(service => service.id === event.service_id);
const signature = event.scope === 'scenario' ? service?.scenario?.signature : service?.signature;
if (!service || signature !== event.signature || now() - event.created_at > 86400000) {
store.notificationResult(event.id, { status: 'discarded', reason: 'obsolete' }, now()); continue;
}
const result = await send(config.telegram, notificationText(event, service, config.telegram));
if (result.ok) store.notificationResult(event.id, { status: 'sent' }, now());
else {
const retry = result.retryable && event.attempts < 7;
const delaySeconds = result.retryAfterSeconds || Math.min(3600, 30 * 2 ** event.attempts);
store.notificationResult(event.id, { status: retry ? 'pending' : 'failed', retryAt: now() + delaySeconds * 1000, reason: result.reason }, now());
// The database holds this incident's recovery until its outage is sent.
}
}
})();
try { await active; } finally { active = null; }
}
async function loop() {
try { await flush(); } catch { console.error('Notification delivery failed; pending events remain in the database.'); }
if (!stopped) timer = setTimeout(loop, 15000);
}
return { flush,
start() { if (!stopped || !config.telegram.enabled) return; stopped = false; void loop(); },
async stop() { stopped = true; clearTimeout(timer); if (active) await active; },
};
}

17
server/notify-cli.mjs Normal file
View File

@@ -0,0 +1,17 @@
import { loadConfig } from './config.mjs';
import { openStore } from './store.mjs';
import { sendTelegram } from './notifications.mjs';
const config = loadConfig();
const command = process.argv[2] || 'status';
if (command === 'status') {
const store = openStore(config.dbPath);
try { console.log(JSON.stringify({ enabled: config.telegram.enabled, queue: store.notificationStats() }, null, 2)); } finally { store.close(); }
} else if (command === 'test') {
if (!config.telegram.enabled) { console.error('Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in .env first.'); process.exitCode = 1; }
else {
const result = await sendTelegram(config.telegram, config.telegram.locale === 'en' ? 'RideBus Status · Test notification. Monitoring alerts are connected.' : 'RideBus Status · Тестовое уведомление. Отправка сообщений подключена.');
console.log(result.ok ? 'Test notification delivered.' : `Delivery failed: ${result.reason}`);
if (!result.ok) process.exitCode = 1;
}
} else { console.error('Usage: npm run notify -- status | test'); process.exitCode = 1; }

85
server/probe.mjs Normal file
View File

@@ -0,0 +1,85 @@
const MAX_BODY_BYTES = 1024 * 1024;
async function bodyText(response) {
if (!response.body) return '';
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MAX_BODY_BYTES) { await reader.cancel(); throw new Error('body-limit'); }
chunks.push(value);
}
} finally { reader.releaseLock(); }
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
return new TextDecoder().decode(bytes);
}
export async function runProbe(probe, { timeoutMs, fetchImpl = fetch }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = performance.now();
let httpCode = null;
try {
let url = probe.url;
let headers = { 'User-Agent': 'RideBusStatus/1.0', ...probe.headers };
let response;
for (let redirects = 0; redirects <= 3; redirects++) {
response = await fetchImpl(url, { signal: controller.signal, redirect: 'manual', headers });
httpCode = response.status;
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get('location');
await response.body?.cancel();
if (!location || redirects === 3) return { ok: false, latencyMs: null, httpCode, reason: 'redirect' };
const next = new URL(location, url);
if (!['http:', 'https:'].includes(next.protocol) || next.username || next.password) return { ok: false, latencyMs: null, httpCode, reason: 'redirect' };
// Never forward secret/custom headers to another origin.
if (next.origin !== new URL(url).origin) headers = { 'User-Agent': 'RideBusStatus/1.0' };
url = next.toString();
}
if (httpCode !== 200) { await response.body?.cancel(); return { ok: false, latencyMs: null, httpCode, reason: 'http' }; }
const text = await bodyText(response);
if (probe.kind === 'html' && (!response.headers.get('content-type')?.toLowerCase().includes('text/html') || !/<html[\s>]/i.test(text))) {
return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
if (probe.kind === 'json') {
let json;
try { json = JSON.parse(text); } catch { return { ok: false, latencyMs: null, httpCode, reason: 'content' }; }
if (probe.expectedJson) {
const actual = probe.expectedJson.path.split('.').reduce((value, part) => value?.[part], json);
if (actual !== probe.expectedJson.value) return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
if (probe.requireDataArray) {
const data = Array.isArray(json) ? json : json?.data;
if (!Array.isArray(data) || !data.length) return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
if (probe.validateJson && !probe.validateJson(json)) return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
return { ok: true, latencyMs: Math.round(performance.now() - started), httpCode, reason: null };
} catch (error) {
return { ok: false, latencyMs: null, httpCode, reason: controller.signal.aborted ? 'timeout' : error.message === 'body-limit' ? 'content' : 'network' };
} finally { clearTimeout(timer); }
}
export async function checkService(service, options) {
if (!service.probes.length) return { ok: false, latencyMs: null, httpCode: null, reason: 'unconfigured' };
const results = await Promise.all(service.probes.map(probe => runProbe(probe, options)));
const failed = results.find(result => !result.ok);
return failed || { ok: true, latencyMs: Math.max(...results.map(result => result.latencyMs)), httpCode: results[0].httpCode, reason: null };
}
export function nextState(previous, result, config) {
if (['unconfigured', 'auth_required', 'browser_unavailable', 'not_run'].includes(result.reason)) return { status: 'unknown', failures: 0, successes: 0 };
const failures = result.ok ? 0 : (previous?.failures || 0) + 1;
const successes = result.ok ? (previous?.successes || 0) + 1 : 0;
let status;
if (!result.ok) status = previous?.status === 'down' || failures >= config.failureThreshold ? 'down' : 'degraded';
else if (previous?.status === 'down' && successes < config.recoveryThreshold) status = 'down';
else status = result.latencyMs > config.slowMs ? 'degraded' : 'operational';
return { status, failures, successes };
}

63
server/reliability.mjs Normal file
View File

@@ -0,0 +1,63 @@
export function unionIntervals(intervals) {
const sorted = intervals.filter(([start, end]) => end > start).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const result = [];
for (const [start, end] of sorted) {
const last = result.at(-1);
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
else result.push([start, end]);
}
return result;
}
export function intersectIntervals(left, right) {
const a = unionIntervals(left), b = unionIntervals(right), result = [];
let i = 0, j = 0;
while (i < a.length && j < b.length) {
const start = Math.max(a[i][0], b[j][0]), end = Math.min(a[i][1], b[j][1]);
if (end > start) result.push([start, end]);
if (a[i][1] <= b[j][1]) i++; else j++;
}
return result;
}
export function subtractIntervals(intervals, excluded) {
const result = [], exclusions = unionIntervals(excluded);
for (const [start, end] of unionIntervals(intervals)) {
let cursor = start;
for (const [a, b] of exclusions) {
if (b <= cursor) continue;
if (a >= end) break;
if (a > cursor) result.push([cursor, Math.min(a, end)]);
cursor = Math.max(cursor, b);
if (cursor >= end) break;
}
if (cursor < end) result.push([cursor, end]);
}
return result;
}
export const intervalDuration = intervals => unionIntervals(intervals).reduce((sum, [start, end]) => sum + end - start, 0);
export function reliabilitySummary({ availability, scenario, incidents, maintenance, since, now, lastRecovery }) {
const excluded = unionIntervals(maintenance);
const observed = subtractIntervals(scenario ? intersectIntervals(availability.known, scenario.known) : availability.known, excluded);
const outages = [];
let incomplete = false;
for (const incident of incidents) {
const expected = subtractIntervals([[Math.max(since, incident.startedAt), Math.min(now, incident.resolvedAt ?? now)]], excluded);
if (!expected.length) continue;
const data = incident.scope === 'scenario' ? scenario : availability;
const measured = intersectIntervals(expected, data?.down || []);
if (intervalDuration(intersectIntervals(expected, data?.known || [])) < intervalDuration(expected)) incomplete = true;
outages.push({ intervals: measured, duration: intervalDuration(measured) });
}
const measured = unionIntervals(outages.flatMap(item => item.intervals));
const duration = intervalDuration(measured);
const hasData = observed.length > 0 || duration > 0;
const usable = hasData && !(incomplete && duration === 0);
return {
downtimeMs: usable ? duration : null,
longestDowntimeMs: usable ? outages.reduce((longest, item) => Math.max(longest, item.duration), 0) : null,
incidentCount: outages.length, downtimeIncomplete: incomplete,
lastRecoveryAt: lastRecovery?.time ?? null, lastRecoveryScope: lastRecovery?.scope ?? null,
observedMs: intervalDuration(observed), coverage: now > since ? Number((intervalDuration(observed) / (now - since) * 100).toFixed(4)) : 0,
since, asOf: now,
};
}

96
server/scenarios.mjs Normal file
View File

@@ -0,0 +1,96 @@
import { runProbe } from './probe.mjs';
import { createBrowserChecks } from './browser-checks.mjs';
const validId = id => Number.isSafeInteger(Number(id)) && Number(id) > 0;
const validBoardConfig = value => validId(value?.cityId) && validId(value?.defaultStopId) && value.configured === true;
const validDeviceState = value => value?.enabled === false || value?.enabled === true && (['unpaired', 'pending', 'expired'].includes(value.state) || value.state === 'paired' && validBoardConfig(value.config));
export const validCatalog = value => Array.isArray(value) && value.length > 0 && value.every(item => validId(item?.id) && typeof item.name === 'string' && Boolean(item.name.trim()));
export const validDepartures = value => value && Array.isArray(value.routes) && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && typeof item.scheduledTime === 'string' && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true;
export const validEta = value => value && Array.isArray(value.routes) && value.routes.every(item => !item.error && validId(item.id) && Array.isArray(item.arrivals));
export const validBoard = value => value && validId(value.stop?.id) && value.mode === 'live' && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true && value.demo !== true;
function selectCity(cities, configuredId) {
const city = configuredId ? cities.find(item => Number(item.id) === configuredId) : cities.find(item => item.slug === 'polotsk') || cities[0];
if (!city) throw { reason: 'content', httpCode: 200 };
return Number(city.id);
}
function selectStop(stops, configuredId) {
const stop = configuredId ? stops.find(item => Number(item.id) === configuredId) : stops.find(item => Number(item.routesCount ?? item.routes_count) > 0) || stops[0];
if (!stop) throw { reason: 'content', httpCode: 200 };
return Number(stop.id);
}
export function createScenarioRunner(config, { fetchImpl = fetch, browserChecks = null } = {}) {
const browser = browserChecks || createBrowserChecks(config);
async function run(service) {
const spec = service.scenario;
const steps = [];
const started = performance.now();
let mode = 'http', current = 'cities';
const target = {};
const result = (ok, reason = null) => ({ ok, reason, latencyMs: ok === true ? Math.round(performance.now() - started) : null, httpCode: steps.find(step => step.status === 'failed')?.httpCode ?? (ok === true ? 200 : null), mode, steps });
async function get(step, path, validator, headers = {}) {
current = step;
let data;
const response = await runProbe({ url: new URL(path, `${spec.baseUrl.replace(/\/+$/, '')}/`).toString(), kind: 'json', headers, validateJson: json => { data = json; return validator(json); } }, { timeoutMs: config.timeoutMs, fetchImpl });
steps.push({ id: step, status: response.ok ? 'passed' : 'failed', latencyMs: response.latencyMs, httpCode: response.httpCode });
if (!response.ok) throw { reason: [401, 403].includes(response.httpCode) || spec.type === 'board' && response.httpCode === 410 ? 'auth_required' : response.reason, httpCode: response.httpCode };
return data;
}
if (!spec || spec.type === 'api' && !spec.apiKey) return result(null, 'unconfigured');
try {
if (spec.type === 'api') {
const headers = { 'X-API-Key': spec.apiKey };
const cities = await get('cities', '/api/v1/cities', validCatalog, headers);
target.cityId = selectCity(cities, spec.cityId);
const scoped = { ...headers, 'X-City-Id': String(target.cityId) };
const stops = await get('stops', '/api/v1/stops', validCatalog, scoped);
target.stopId = selectStop(stops, spec.stopId);
await get('departures', `/api/v1/eta/stop/${target.stopId}`, validEta, scoped);
} else if (spec.type === 'pwa') {
const cities = await get('cities', '/api/data?resource=cities', validCatalog);
target.cityId = selectCity(cities, spec.cityId);
const stops = await get('stops', `/api/data?resource=stops&cityId=${target.cityId}`, validCatalog);
target.stopId = selectStop(stops, spec.stopId);
await get('departures', `/api/data?resource=departures&cityId=${target.cityId}&stopId=${target.stopId}`, validDepartures);
} else {
const headers = spec.cookie ? { Cookie: spec.cookie } : {};
let settings = await get('config', '/api/config', validBoardConfig, headers);
let managed = false;
if (spec.cookie) {
const device = await get('device', '/api/device/status', validDeviceState, headers);
if (device.enabled) {
if (device.state !== 'paired') {
steps.at(-1).status = 'failed';
throw { reason: 'auth_required', httpCode: 200 };
}
// A paired board can only request the stop assigned by the admin.
// /api/config describes the standalone defaults, not this device.
settings = device.config;
managed = true;
}
}
target.cityId = Number(settings.cityId);
const stops = await get('stops', '/api/stops', validCatalog, headers);
target.stopId = managed ? Number(settings.defaultStopId) : spec.stopId || Number(settings.defaultStopId);
if (!stops.some(stop => Number(stop.id) === target.stopId)) throw { reason: 'content' };
await get('departures', `/api/board?stopId=${target.stopId}`, value => validBoard(value) && Number(value.stop.id) === target.stopId, headers);
}
if (spec.type !== 'api') {
current = 'render';
if (!config.browser.enabled) steps.push({ id: 'render', status: 'waiting', latencyMs: null, httpCode: null });
else {
const rendered = await browser.run(spec, target);
steps.push({ id: 'render', status: rendered.ok === null ? 'waiting' : rendered.ok ? 'passed' : 'failed', latencyMs: rendered.latencyMs ?? null, httpCode: rendered.httpCode ?? null });
if (rendered.ok !== true) return result(rendered.ok, rendered.reason);
mode = 'browser';
}
}
return result(true);
} catch (error) {
if (steps.at(-1)?.status !== 'failed') steps.push({ id: current, status: 'failed', latencyMs: null, httpCode: error.httpCode ?? null });
return result(error.reason === 'auth_required' ? null : false, error.reason || 'content');
}
}
return { run, close: () => browser.close() };
}

83
server/snapshot.mjs Normal file
View File

@@ -0,0 +1,83 @@
const DAY = 86400000;
import { affectsService, combinedStatus, maintenancePhase } from './maintenance.mjs';
import { incidentImpact, serviceImpact } from '../shared/impact.mjs';
export function summarize(checks) {
const known = checks.filter(check => check.reason !== 'unconfigured');
const successful = known.filter(check => check.ok);
return {
checks: known.length,
uptime: known.length ? Number((successful.length / known.length * 100).toFixed(2)) : null,
latencyMs: successful.length ? Math.round(successful.reduce((sum, check) => sum + check.latencyMs, 0) / successful.length) : null,
};
}
export function overallStatus(services) {
const states = services.map(service => service.status);
if (states.every(status => status === 'unknown')) return 'unknown';
if (states.every(status => status === 'down')) return 'down';
if (states.some(status => status === 'down' || status === 'degraded')) return 'degraded';
if (states.some(status => status === 'unknown')) return 'unknown';
if (states.some(status => status === 'maintenance')) return 'maintenance';
return 'operational';
}
export function makeSnapshot(store, config, range = 30, now = Date.now()) {
// UTC day boundaries are stable across the monitor and visitor timezones.
const today = Math.floor(now / DAY) * DAY;
const since = today - (range - 1) * DAY;
const maintenance = store.maintenances().map(item => ({ ...item, phase: maintenancePhase(item, now) }));
const services = config.services.map(service => {
const state = store.state(service);
const statistics = store.statistics(service, since, now);
const latest = statistics.recent[0];
const days = new Map(statistics.days.map(day => [day.start, day]));
const hours = new Map(statistics.hours.map(hour => [hour.bucket, hour]));
const summaryOf = summary => ({ checks: summary?.checks || 0, uptime: summary?.checks ? Number((summary.successes / summary.checks * 100).toFixed(2)) : null, latencyMs: summary?.latencyMs == null ? null : Math.round(summary.latencyMs) });
const summary = summaryOf(statistics.summary);
const stale = !state || now - state.checked_at > config.staleAfterSeconds * 1000;
const scenarioState = store.scenario(service);
const scenarioStale = !scenarioState || now - scenarioState.checked_at > config.scenarioIntervalSeconds * 3000;
const scenario = service.scenario ? {
type: service.scenario.type, status: scenarioStale ? 'unknown' : scenarioState.status,
lastCheckedAt: scenarioState?.checked_at || null, staleAfterSeconds: config.scenarioIntervalSeconds * 3,
intervalSeconds: config.scenarioIntervalSeconds, stale: Boolean(scenarioState && scenarioStale),
reason: scenarioStale ? 'not_run' : scenarioState.reason, mode: scenarioState?.mode || 'http',
pending: Boolean(scenarioState && (scenarioState.failures > 0 && scenarioState.failures < config.failureThreshold || scenarioState.status === 'down' && scenarioState.successes > 0)),
steps: scenarioState?.steps || [],
} : null;
const activeMaintenance = maintenance.filter(item => affectsService(item, service.id, now)).map(item => item.id);
const availabilityStatus = stale ? 'unknown' : state.status;
const history = Array.from({ length: range }, (_, index) => {
const start = since + index * DAY;
const samples = days.get(start);
const summary = summaryOf(samples);
const status = samples ? samples.severity === 2 ? 'down' : samples.severity === 1 ? 'degraded' : 'operational' : 'unknown';
// A sparse or interrupted day must not appear to have complete coverage.
const observableDuration = Math.max(0, Math.min(now, start + DAY) - start);
const expected = Math.max(1, Math.floor(observableDuration / (config.intervalSeconds * 1000)));
const coverage = Math.min(100, Number((summary.checks / expected * 100).toFixed(1)));
return { date: new Date(start).toISOString().slice(0, 10), status, ...summary, coverage };
});
const responseHistory = Array.from({ length: 24 }, (_, index) => {
const start = now - (24 - index) * 3600000;
return { time: start, ...summaryOf(hours.get(index)) };
});
return {
id: service.id, icon: service.icon, name: service.name, description: service.description,
publicUrl: service.publicUrl, kind: service.kind, functional: service.functional, impactPolicy: service.impactPolicy,
configured: service.probes.length > 0, status: combinedStatus(availabilityStatus, scenario, activeMaintenance.length > 0), availabilityStatus, scenario, activeMaintenance,
stale: Boolean(state && stale), pending: Boolean(state && (state.failures > 0 && state.failures < config.failureThreshold || state.status === 'down' && state.successes > 0)),
lastCheckedAt: state?.checked_at || null, latencyMs: latest?.latencyMs ?? null,
httpCode: latest?.httpCode ?? null, reason: latest?.reason ?? null,
uptime: summary.uptime, checks: summary.checks,
avgLatencyMs: summary.latencyMs, history, responseHistory,
reliability: store.reliability(service, since, now, config), latency: store.latencyDetails(service, since, now, config.intervalSeconds), latency24h: store.latencyDetails(service, now - DAY, now, config.intervalSeconds),
latencyHistory: history.map(day => ({ time: new Date(day.date).getTime(), checks: day.checks, uptime: day.uptime, latencyMs: day.latencyMs })),
recentChecks: statistics.recent.map(({ time, ok, latencyMs, httpCode, reason }) => ({ time, ok: Boolean(ok), latencyMs, httpCode, reason })),
};
});
services.forEach(service => { service.impact = serviceImpact(service, services); });
const times = services.map(service => service.lastCheckedAt).filter(Boolean);
const incidents = store.incidents().filter(incident => incident.source === 'manual' || config.services.some(service => service.id === incident.serviceId && (incident.scope === 'scenario' ? service.scenario?.signature : service.signature) === incident.signature)).map(({ signature: _signature, ...incident }) => ({ ...incident, impact: incidentImpact(incident, services) }));
return { generatedAt: now, lastCheckAt: times.length ? Math.min(...times) : null, intervalSeconds: config.intervalSeconds, scenarioIntervalSeconds: config.scenarioIntervalSeconds, staleAfterSeconds: config.staleAfterSeconds, failureThreshold: config.failureThreshold, recoveryThreshold: config.recoveryThreshold, slowMs: config.slowMs, range, demo: false, overall: overallStatus(services), services, incidents, maintenance };
}

261
server/store.mjs Normal file
View File

@@ -0,0 +1,261 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { affectsService } from './maintenance.mjs';
import { validateUpdate } from './incident-updates.mjs';
import { maintenanceEnd } from './maintenance.mjs';
import { reliabilitySummary } from './reliability.mjs';
export function openStore(path) {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
const db = new DatabaseSync(path);
let revision = 0;
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY, service_id TEXT NOT NULL, signature TEXT NOT NULL,
checked_at INTEGER NOT NULL, ok INTEGER NOT NULL, status TEXT NOT NULL,
latency_ms INTEGER, http_code INTEGER, reason TEXT
);
CREATE INDEX IF NOT EXISTS checks_service_time ON checks(service_id, signature, checked_at);
CREATE TABLE IF NOT EXISTS service_state (
service_id TEXT NOT NULL, signature TEXT NOT NULL, status TEXT NOT NULL,
failures INTEGER NOT NULL, successes INTEGER NOT NULL, checked_at INTEGER NOT NULL,
PRIMARY KEY (service_id, signature)
);
CREATE TABLE IF NOT EXISTS incidents (
id INTEGER PRIMARY KEY, source TEXT NOT NULL, service_id TEXT,
signature TEXT, started_at INTEGER NOT NULL, resolved_at INTEGER,
title_ru TEXT NOT NULL, title_en TEXT NOT NULL,
message_ru TEXT NOT NULL, message_en TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS maintenance (
id INTEGER PRIMARY KEY, title_ru TEXT NOT NULL, title_en TEXT NOT NULL,
message_ru TEXT NOT NULL, message_en TEXT NOT NULL, service_ids TEXT NOT NULL,
starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
cancelled_at INTEGER, completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS scenario_state (
service_id TEXT NOT NULL, signature TEXT NOT NULL, status TEXT NOT NULL,
failures INTEGER NOT NULL, successes INTEGER NOT NULL, checked_at INTEGER NOT NULL,
ok INTEGER, latency_ms INTEGER, reason TEXT, mode TEXT NOT NULL, steps TEXT NOT NULL,
PRIMARY KEY (service_id, signature)
);
CREATE TABLE IF NOT EXISTS notification_outbox (
id INTEGER PRIMARY KEY, event_key TEXT NOT NULL UNIQUE, incident_id INTEGER NOT NULL,
service_id TEXT NOT NULL, signature TEXT NOT NULL, scope TEXT NOT NULL, kind TEXT NOT NULL,
created_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL, delivered_at INTEGER, last_error TEXT
);
CREATE TABLE IF NOT EXISTS incident_updates (
id INTEGER PRIMARY KEY, incident_id INTEGER NOT NULL REFERENCES incidents(id),
source TEXT NOT NULL, stage TEXT NOT NULL, created_at INTEGER NOT NULL,
message_ru TEXT NOT NULL, message_en TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS incident_updates_time ON incident_updates(incident_id, created_at, id);
CREATE TABLE IF NOT EXISTS scenario_checks (
id INTEGER PRIMARY KEY, service_id TEXT NOT NULL, signature TEXT NOT NULL,
checked_at INTEGER NOT NULL, status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS scenario_checks_time ON scenario_checks(service_id, signature, checked_at);
`);
// Existing MVP databases are upgraded once at startup without losing history.
if (!db.prepare('PRAGMA table_info(incidents)').all().some(column => column.name === 'scope')) db.exec("ALTER TABLE incidents ADD COLUMN scope TEXT NOT NULL DEFAULT 'availability'");
// Backfill only facts present in the old database; never invent team actions.
db.exec(`
INSERT INTO incident_updates(incident_id, source, stage, created_at, message_ru, message_en)
SELECT i.id, i.source, CASE i.source WHEN 'monitor' THEN 'detected' ELSE 'reported' END, i.started_at, i.message_ru, i.message_en
FROM incidents i WHERE NOT EXISTS (SELECT 1 FROM incident_updates u WHERE u.incident_id = i.id);
INSERT INTO incident_updates(incident_id, source, stage, created_at, message_ru, message_en)
SELECT i.id, i.source, 'resolved', i.resolved_at,
CASE i.source WHEN 'monitor' THEN 'Повторные проверки подтвердили восстановление.' ELSE 'Команда отметила инцидент как решённый.' END,
CASE i.source WHEN 'monitor' THEN 'Repeated checks confirmed recovery.' ELSE 'The team marked the incident as resolved.' END
FROM incidents i WHERE i.resolved_at IS NOT NULL AND NOT EXISTS (SELECT 1 FROM incident_updates u WHERE u.incident_id = i.id AND u.stage = 'resolved');
`);
const insertUpdate = db.prepare('INSERT INTO incident_updates(incident_id, source, stage, created_at, message_ru, message_en) VALUES (?, ?, ?, ?, ?, ?)');
function transaction(action) {
db.exec('BEGIN IMMEDIATE');
try { const value = action(); db.exec('COMMIT'); revision++; return value; } catch (error) { db.exec('ROLLBACK'); throw error; }
}
const stateStatement = db.prepare('SELECT * FROM service_state WHERE service_id = ? AND signature = ?');
const saveState = db.prepare('INSERT INTO service_state VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(service_id, signature) DO UPDATE SET status = excluded.status, failures = excluded.failures, successes = excluded.successes, checked_at = excluded.checked_at');
const insertCheck = db.prepare('INSERT INTO checks(service_id, signature, checked_at, ok, status, latency_ms, http_code, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
const openIncident = db.prepare("SELECT id FROM incidents WHERE source = 'monitor' AND service_id = ? AND signature = ? AND scope = ? AND resolved_at IS NULL");
const createIncident = db.prepare('INSERT INTO incidents(source, service_id, signature, started_at, title_ru, title_en, message_ru, message_en, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
const saveScenario = db.prepare('INSERT INTO scenario_state VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(service_id, signature) DO UPDATE SET status = excluded.status, failures = excluded.failures, successes = excluded.successes, checked_at = excluded.checked_at, ok = excluded.ok, latency_ms = excluded.latency_ms, reason = excluded.reason, mode = excluded.mode, steps = excluded.steps');
const insertScenarioCheck = db.prepare('INSERT INTO scenario_checks(service_id, signature, checked_at, status) VALUES (?, ?, ?, ?)');
function spanQuery(table) {
return db.prepare(`WITH ordered AS (
SELECT checked_at, status, LEAD(checked_at, 1, ?4) OVER (ORDER BY checked_at, id) AS next_at
FROM ${table} WHERE service_id = ?1 AND signature = ?2 AND checked_at >= ?3 - ?5 AND checked_at <= ?4
), spans AS (
SELECT kind, MAX(checked_at, ?3) AS start, MIN(next_at, checked_at + ?5, ?4) AS end
FROM ordered CROSS JOIN (SELECT 'known' AS kind UNION ALL SELECT 'down')
WHERE status IN ('operational', 'degraded', 'down') AND (kind = 'known' OR status = 'down')
), positive AS (SELECT * FROM spans WHERE end > start), previous AS (
SELECT *, MAX(end) OVER (PARTITION BY kind ORDER BY start, end ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS previous_end FROM positive
), islands AS (
SELECT *, SUM(CASE WHEN previous_end IS NULL OR start > previous_end THEN 1 ELSE 0 END) OVER (PARTITION BY kind ORDER BY start, end) AS island FROM previous
) SELECT kind, MIN(start) AS start, MAX(end) AS end FROM islands GROUP BY kind, island ORDER BY kind, start`);
}
const availabilitySpans = spanQuery('checks'), scenarioSpans = spanQuery('scenario_checks');
function readSpans(query, id, signature, since, now, staleMs) {
const result = { known: [], down: [] };
for (const row of query.all(id, signature, since, now, staleMs)) result[row.kind].push([row.start, row.end]);
return result;
}
function maintenanceRows() {
return db.prepare('SELECT * FROM maintenance ORDER BY starts_at DESC, id DESC LIMIT 100').all().map(row => ({
id: String(row.id), title: { ru: row.title_ru, en: row.title_en }, message: { ru: row.message_ru, en: row.message_en },
serviceIds: JSON.parse(row.service_ids), startsAt: row.starts_at, endsAt: row.ends_at, createdAt: row.created_at,
cancelledAt: row.cancelled_at, completedAt: row.completed_at,
}));
}
function inMaintenance(service, time) { return maintenanceRows().some(item => affectsService(item, service.id, time)); }
function enqueue(id, service, signature, scope, kind, time) {
db.prepare('INSERT OR IGNORE INTO notification_outbox(event_key, incident_id, service_id, signature, scope, kind, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(`${id}:${kind}`, id, service.id, signature, scope, kind, time, time);
}
function updateIncident(service, signature, scope, result, state, time, notify) {
const incident = openIncident.get(service.id, signature, scope);
if (state.status === 'down' && !incident && !inMaintenance(service, time)) {
const scenario = scope === 'scenario';
const created = createIncident.run('monitor', service.id, signature, time,
`${service.name.ru}: ${scenario ? 'сценарий недоступен' : 'сервис недоступен'}`,
`${service.name.en}: ${scenario ? 'user journey unavailable' : 'service unavailable'}`,
scenario ? 'Повторные проверки подтвердили проблему с получением или отображением данных.' : 'Мониторинг подтвердил проблему после нескольких проверок. Ожидаем восстановления.',
scenario ? 'Repeated checks confirmed a problem retrieving or displaying data.' : 'Monitoring confirmed a problem after repeated checks. Waiting for recovery.', scope);
const id = Number(created.lastInsertRowid);
insertUpdate.run(id, 'monitor', 'detected', time, 'Повторные проверки подтвердили сбой. Сообщений команды пока нет.', 'Repeated checks confirmed an outage. No team update yet.');
if (notify) enqueue(id, service, signature, scope, 'outage', time);
} else if (incident && (state.status === 'operational' || state.status === 'degraded' && result.ok)) {
db.prepare('UPDATE incidents SET resolved_at = ? WHERE id = ?').run(time, incident.id);
insertUpdate.run(incident.id, 'monitor', 'resolved', time, 'Повторные проверки подтвердили восстановление.', 'Repeated checks confirmed recovery.');
if (notify) enqueue(incident.id, service, signature, scope, 'recovery', time);
}
}
return {
dataRevision() { return `${revision}:${db.prepare('PRAGMA data_version').get().data_version}`; },
state(service) { return stateStatement.get(service.id, service.signature); },
record(service, result, state, time = Date.now(), { notify = false } = {}) {
db.exec('BEGIN IMMEDIATE');
try {
insertCheck.run(service.id, service.signature, time, Number(result.ok), state.status, result.latencyMs, result.httpCode, result.reason);
saveState.run(service.id, service.signature, state.status, state.failures, state.successes, time);
updateIncident(service, service.signature, 'availability', result, state, time, notify);
db.exec('COMMIT');
revision++;
} catch (error) { db.exec('ROLLBACK'); throw error; }
},
scenario(service) {
if (!service.scenario) return null;
const row = db.prepare('SELECT * FROM scenario_state WHERE service_id = ? AND signature = ?').get(service.id, service.scenario.signature);
return row ? { ...row, steps: JSON.parse(row.steps) } : null;
},
recordScenario(service, result, state, time = Date.now(), { notify = false } = {}) {
db.exec('BEGIN IMMEDIATE');
try {
saveScenario.run(service.id, service.scenario.signature, state.status, state.failures, state.successes, time, result.ok === null ? null : Number(result.ok), result.latencyMs, result.reason, result.mode || 'http', JSON.stringify(result.steps || []));
insertScenarioCheck.run(service.id, service.scenario.signature, time, state.status);
updateIncident(service, service.scenario.signature, 'scenario', result, state, time, notify);
db.exec('COMMIT');
revision++;
} catch (error) { db.exec('ROLLBACK'); throw error; }
},
hasOpenIncident(service, scope = 'availability') { return Boolean(openIncident.get(service.id, scope === 'scenario' ? service.scenario?.signature : service.signature, scope)); },
inMaintenance,
maintenances: maintenanceRows,
createMaintenance({ titleRu, titleEn, messageRu, messageEn, serviceIds, startsAt, endsAt }, time = Date.now()) {
const result = db.prepare('INSERT INTO maintenance(title_ru, title_en, message_ru, message_en, service_ids, starts_at, ends_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(titleRu, titleEn, messageRu, messageEn, JSON.stringify(serviceIds), startsAt, endsAt, time);
revision++;
return String(result.lastInsertRowid);
},
endMaintenance(id, kind, time = Date.now()) {
if (!['cancel', 'finish'].includes(kind)) throw new Error('Use cancel or finish');
const field = kind === 'cancel' ? 'cancelled_at' : 'completed_at';
const boundary = kind === 'finish' ? 'starts_at <= ?' : 'ends_at > ?';
const changed = db.prepare(`UPDATE maintenance SET ${field} = ? WHERE id = ? AND cancelled_at IS NULL AND completed_at IS NULL AND ${boundary}`).run(time, id, time).changes > 0;
if (changed) revision++;
return changed;
},
dueNotifications(time = Date.now()) { return db.prepare("SELECT n.*, i.started_at, i.resolved_at FROM notification_outbox n JOIN incidents i ON i.id = n.incident_id WHERE n.status = 'pending' AND n.next_attempt_at <= ? AND NOT EXISTS (SELECT 1 FROM notification_outbox earlier WHERE earlier.incident_id = n.incident_id AND earlier.id < n.id AND earlier.status = 'pending') ORDER BY n.id LIMIT 20").all(time); },
notificationResult(id, { status, retryAt = null, reason = null }, time = Date.now()) {
db.prepare("UPDATE notification_outbox SET status = ?, attempts = attempts + 1, next_attempt_at = COALESCE(?, next_attempt_at), delivered_at = CASE WHEN ? = 'sent' THEN ? ELSE NULL END, last_error = ? WHERE id = ?").run(status, retryAt, status, time, reason, id);
},
notificationStats() { return db.prepare('SELECT status, COUNT(*) AS count FROM notification_outbox GROUP BY status').all(); },
checks(service, since) { return db.prepare('SELECT checked_at AS time, ok, status, latency_ms AS latencyMs, http_code AS httpCode, reason FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? ORDER BY checked_at, id').all(service.id, service.signature, since); },
reliability(service, since, now, config) {
const availability = readSpans(availabilitySpans, service.id, service.signature, since, now, config.staleAfterSeconds * 1000);
const scenario = service.scenario ? readSpans(scenarioSpans, service.id, service.scenario.signature, since, now, config.scenarioIntervalSeconds * 3000) : null;
const incidents = db.prepare("SELECT scope, started_at AS startedAt, resolved_at AS resolvedAt FROM incidents WHERE source = 'monitor' AND service_id = ? AND ((scope = 'availability' AND signature = ?) OR (scope = 'scenario' AND signature = ?)) AND started_at < ? AND (resolved_at IS NULL OR resolved_at > ?)").all(service.id, service.signature, service.scenario?.signature || '', now, since);
const lastRecovery = db.prepare("SELECT resolved_at AS time, scope FROM incidents WHERE source = 'monitor' AND service_id = ? AND ((scope = 'availability' AND signature = ?) OR (scope = 'scenario' AND signature = ?)) AND resolved_at <= ? ORDER BY resolved_at DESC, id DESC LIMIT 1").get(service.id, service.signature, service.scenario?.signature || '', now);
const maintenance = db.prepare('SELECT * FROM maintenance WHERE starts_at < ? AND ends_at > ?').all(now, since).flatMap(row => {
const ids = JSON.parse(row.service_ids);
return !ids.length || ids.includes(service.id) ? [[row.starts_at, maintenanceEnd({ endsAt: row.ends_at, cancelledAt: row.cancelled_at, completedAt: row.completed_at })]] : [];
});
return reliabilitySummary({ availability, scenario, incidents, maintenance, since, now, lastRecovery });
},
latencyDetails(service, since, now, intervalSeconds = 60) {
const summary = db.prepare('SELECT COUNT(*) AS samples, AVG(latency_ms) AS average, MIN(checked_at) AS firstAt, MAX(checked_at) AS lastAt FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND checked_at <= ? AND ok = 1 AND latency_ms IS NOT NULL');
const current = summary.get(service.id, service.signature, since, now);
const baseline = summary.get(service.id, service.signature, since - (now - since), since - 1);
const p95 = current.samples ? db.prepare('SELECT latency_ms FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND checked_at <= ? AND ok = 1 AND latency_ms IS NOT NULL ORDER BY latency_ms LIMIT 1 OFFSET ?').get(service.id, service.signature, since, now, Math.ceil(current.samples * .95) - 1).latency_ms : null;
// Compare populated windows, rather than a handful of samples against a month.
const minimumSamples = Math.max(3, Math.floor((now - since) / (intervalSeconds * 1000) * .5));
const comparable = [current, baseline].every(item => item.samples >= minimumSamples && item.lastAt - item.firstAt >= (now - since) * .5);
const percent = comparable && baseline.average > 0 ? Number(((current.average / baseline.average - 1) * 100).toFixed(1)) : null;
return { averageMs: current.average === null ? null : Math.round(current.average), p95Ms: p95, samples: current.samples, previousAverageMs: comparable ? Math.round(baseline.average) : null, changePercent: percent };
},
statistics(service, since, now) {
const params = [service.id, service.signature, since];
const summary = db.prepare("SELECT COUNT(*) AS checks, SUM(ok) AS successes, AVG(CASE WHEN ok = 1 THEN latency_ms END) AS latencyMs FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND reason IS NOT 'unconfigured'").get(...params);
const days = db.prepare("SELECT (checked_at / 86400000) * 86400000 AS start, COUNT(*) AS checks, SUM(ok) AS successes, AVG(CASE WHEN ok = 1 THEN latency_ms END) AS latencyMs, MAX(CASE status WHEN 'down' THEN 2 WHEN 'degraded' THEN 1 ELSE 0 END) AS severity FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND reason IS NOT 'unconfigured' GROUP BY start").all(...params);
const responseSince = now - 24 * 3600000;
const hours = db.prepare("SELECT CAST((checked_at - ?) / 3600000 AS INTEGER) AS bucket, COUNT(*) AS checks, SUM(ok) AS successes, AVG(CASE WHEN ok = 1 THEN latency_ms END) AS latencyMs FROM checks WHERE service_id = ? AND signature = ? AND checked_at >= ? AND checked_at < ? AND reason IS NOT 'unconfigured' GROUP BY bucket").all(responseSince, service.id, service.signature, responseSince, now);
const recent = db.prepare('SELECT checked_at AS time, ok, status, latency_ms AS latencyMs, http_code AS httpCode, reason FROM checks WHERE service_id = ? AND signature = ? ORDER BY checked_at DESC, id DESC LIMIT 12').all(service.id, service.signature);
return { summary, days, hours, recent };
},
incidents() {
return db.prepare('SELECT * FROM incidents ORDER BY started_at DESC, id DESC LIMIT 100').all().map(row => ({
id: String(row.id), source: row.source, serviceId: row.service_id,
signature: row.signature, scope: row.scope, startedAt: row.started_at, resolvedAt: row.resolved_at,
title: { ru: row.title_ru, en: row.title_en }, message: { ru: row.message_ru, en: row.message_en },
updates: db.prepare('SELECT * FROM incident_updates WHERE incident_id = ? ORDER BY created_at, id').all(row.id).map(update => ({ id: String(update.id), source: update.source, stage: update.stage, createdAt: update.created_at, message: { ru: update.message_ru, en: update.message_en } })),
}));
},
createIncident({ serviceId = null, titleRu, titleEn, messageRu, messageEn }, time = Date.now()) {
return transaction(() => {
const result = createIncident.run('manual', serviceId, null, time, titleRu, titleEn, messageRu, messageEn, 'availability');
insertUpdate.run(result.lastInsertRowid, 'manual', 'reported', time, messageRu, messageEn);
return String(result.lastInsertRowid);
});
},
updateIncident(id, input, time = Date.now()) {
const value = validateUpdate(input);
return transaction(() => {
const incident = db.prepare('SELECT * FROM incidents WHERE id = ?').get(id);
if (!incident) throw new Error('Incident not found');
if (incident.resolved_at !== null && value.stage !== 'postmortem') throw new Error('Closed incidents accept postmortem updates only');
if (incident.resolved_at === null && value.stage === 'postmortem') throw new Error('Resolve the incident before adding a postmortem');
const latest = db.prepare('SELECT MAX(created_at) AS time FROM incident_updates WHERE incident_id = ?').get(id);
if (!Number.isSafeInteger(time) || time < Math.max(incident.started_at, latest.time || 0)) throw new Error('Update must follow the incident timeline');
const result = insertUpdate.run(id, 'manual', value.stage, time, value.messageRu, value.messageEn);
return String(result.lastInsertRowid);
});
},
resolveIncident(id, time = Date.now(), input = null) {
return transaction(() => {
const row = db.prepare("SELECT * FROM incidents WHERE id = ? AND source = 'manual' AND resolved_at IS NULL").get(id);
if (!row) return false;
const latest = db.prepare('SELECT MAX(created_at) AS time FROM incident_updates WHERE incident_id = ?').get(id);
if (time < Math.max(row.started_at, latest.time || 0)) throw new Error('Resolution must follow the incident timeline');
const text = input ? validateUpdate({ ...input, stage: 'monitoring' }) : { messageRu: 'Команда отметила инцидент как решённый.', messageEn: 'The team marked the incident as resolved.' };
db.prepare('UPDATE incidents SET resolved_at = ? WHERE id = ?').run(time, id);
insertUpdate.run(id, 'manual', 'resolved', time, text.messageRu, text.messageEn);
return true;
});
},
prune(since) { db.prepare('DELETE FROM checks WHERE checked_at < ?').run(since); db.prepare('DELETE FROM scenario_checks WHERE checked_at < ?').run(since); revision++; },
close() { db.close(); },
};
}