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