initial commit
This commit is contained in:
69
server/browser-checks.mjs
Normal file
69
server/browser-checks.mjs
Normal 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
78
server/config.mjs
Normal 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
73
server/demo.mjs
Normal 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
55
server/feed.mjs
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>`;
|
||||
}
|
||||
17
server/incident-updates.mjs
Normal file
17
server/incident-updates.mjs
Normal 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
40
server/incidents-cli.mjs
Normal 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
96
server/index.mjs
Normal 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); });
|
||||
}
|
||||
23
server/maintenance-cli.mjs
Normal file
23
server/maintenance-cli.mjs
Normal 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
37
server/maintenance.mjs
Normal 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
60
server/monitor.mjs
Normal 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
63
server/notifications.mjs
Normal 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
17
server/notify-cli.mjs
Normal 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
85
server/probe.mjs
Normal 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
63
server/reliability.mjs
Normal 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
96
server/scenarios.mjs
Normal 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
83
server/snapshot.mjs
Normal 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
261
server/store.mjs
Normal 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(); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user