Files
ridebus-status/server/snapshot.mjs
2026-10-13 02:24:39 +03:00

84 lines
6.4 KiB
JavaScript

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 };
}