diff --git a/.changeset/admin-network-health-history.md b/.changeset/admin-network-health-history.md new file mode 100644 index 000000000..39ea593ee --- /dev/null +++ b/.changeset/admin-network-health-history.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add bounded NIP-66 probe history API and Network Health dashboard timeline diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0cdd83603..b3f99947a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -228,6 +228,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. | | nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Addresses from `mirroring.static[]` are probed for the admin snapshot but are not published as kind `30166` discovery events. | | nip66.geohash | Optional NIP-52 geohash describing this monitor's network vantage. Published on kind `10166` monitor announcements when set. | +| nip66.historyRetentionRuns | Maximum probe run snapshots kept for the admin Network Health history API and timeline. Defaults to 168. | +| nip66.historyRetentionHours | Optional maximum age in hours for retained probe history. When unset, history is trimmed by run count only. | | nip66.timeouts.dnsMs | DNS probe timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.tlsMs | TLS probe timeout in milliseconds. Defaults to 10000. | diff --git a/resources/admin/assets/dashboard.css b/resources/admin/assets/dashboard.css index ba2f6dc5e..e0954bde2 100644 --- a/resources/admin/assets/dashboard.css +++ b/resources/admin/assets/dashboard.css @@ -754,6 +754,30 @@ } } +.network-health-timeline { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 12rem; + overflow-y: auto; +} + +.network-health-timeline-item { + background: var(--panel); + border: 1px solid var(--panel-border); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.72rem; + padding: 0.45rem 0.65rem; + text-align: left; + width: 100%; +} + +.network-health-timeline-item.is-selected { + border-color: var(--accent); + color: var(--text); +} + .network-health-results { display: grid; gap: 0.75rem; diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 22b848486..b650ca3df 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -40,6 +40,8 @@ const networkHealthSummary = document.getElementById('network-health-summary') const networkHealthResults = document.getElementById('network-health-results') const networkHealthRunAt = document.getElementById('network-health-run-at') + const networkHealthTimelineWrap = document.getElementById('network-health-timeline-wrap') + const networkHealthTimeline = document.getElementById('network-health-timeline') let settingsLoaded = false let settingsLoading = false @@ -59,6 +61,10 @@ const staleThresholdMs = 15000 let networkHealthPollTimer const networkHealthPollIntervalMs = 60000 + const networkHealthHistoryLimit = 24 + let networkHealthHistoryRuns = [] + let selectedNetworkHealthRunAt = null + let networkHealthHistoryPinned = false const statusClasses = ['status-ok', 'status-degraded', 'status-unavailable', 'status-down', 'status-no-data'] @@ -360,6 +366,7 @@ startMetricsStream() void refreshNetworkHealth() + void refreshNetworkHealthHistory() startNetworkHealthPolling() } @@ -374,6 +381,7 @@ stopNetworkHealthPolling() networkHealthPollTimer = setInterval(() => { void refreshNetworkHealth() + void refreshNetworkHealthHistory() }, networkHealthPollIntervalMs) } @@ -412,6 +420,52 @@ } } + const renderNetworkHealthTimeline = (runs) => { + if (!networkHealthTimeline || !networkHealthTimelineWrap) { + return + } + + networkHealthTimeline.replaceChildren() + + if (!Array.isArray(runs) || runs.length === 0) { + networkHealthTimelineWrap.classList.add('d-none') + return + } + + networkHealthTimelineWrap.classList.remove('d-none') + + runs.forEach((run) => { + const runAt = run?.runAt + const button = document.createElement('button') + button.type = 'button' + button.className = 'network-health-timeline-item' + button.setAttribute('role', 'listitem') + + const status = run?.status ?? 'failed' + const statusClass = + status === 'ok' ? 'status-ok' : status === 'partial' ? 'status-degraded' : 'status-down' + button.classList.add(statusClass) + + if (runAt && runAt === selectedNetworkHealthRunAt) { + button.classList.add('is-selected') + } + + const runAtMs = Date.parse(runAt ?? '') + const label = Number.isFinite(runAtMs) ? new Date(runAtMs).toISOString() : 'Unknown run' + const targetCount = Array.isArray(run?.results) ? run.results.length : 0 + button.textContent = `${label} · ${status} · ${targetCount} target(s)` + + button.addEventListener('click', () => { + networkHealthHistoryPinned = true + selectedNetworkHealthRunAt = runAt ?? null + renderNetworkHealthTimeline(networkHealthHistoryRuns) + renderNetworkHealthSnapshot(run) + }) + + networkHealthTimeline.appendChild(button) + }) + } + const renderNetworkHealthSnapshot = (snapshot) => { if (!networkHealthEmpty || !networkHealthSummary || !networkHealthResults) { return @@ -422,6 +476,9 @@ networkHealthSummary.classList.add('d-none') networkHealthResults.classList.add('d-none') networkHealthResults.replaceChildren() + if (networkHealthTimelineWrap) { + networkHealthTimelineWrap.classList.add('d-none') + } setNetworkHealthSyncLine('no probe snapshot available') return } @@ -614,12 +671,53 @@ } const body = await response.json() - renderNetworkHealthSnapshot(body.snapshot ?? null) + const snapshot = body.snapshot ?? null + let displaySnapshot = snapshot + if (networkHealthHistoryPinned && selectedNetworkHealthRunAt && networkHealthHistoryRuns.length > 0) { + const selected = networkHealthHistoryRuns.find((run) => run?.runAt === selectedNetworkHealthRunAt) + if (selected) { + displaySnapshot = selected + } else { + networkHealthHistoryPinned = false + selectedNetworkHealthRunAt = null + } + } + renderNetworkHealthSnapshot(displaySnapshot) + if (networkHealthHistoryRuns.length > 0) { + renderNetworkHealthTimeline(networkHealthHistoryRuns) + } } catch { setNetworkHealthSyncLine('network error while loading probes') } } + const refreshNetworkHealthHistory = async () => { + if (!networkHealthTimeline) { + return + } + + try { + const response = await fetch(`${adminBase}/network-health/history?limit=${networkHealthHistoryLimit}`, { + credentials: 'include', + }) + + if (response.status === 401) { + showLogin() + return + } + + if (!response.ok) { + return + } + + const body = await response.json() + networkHealthHistoryRuns = Array.isArray(body.runs) ? body.runs : [] + renderNetworkHealthTimeline(networkHealthHistoryRuns) + } catch { + // Timeline is optional; keep the latest snapshot panel usable. + } + } + const parsePathTokens = (path) => { const tokens = [] const pattern = /([^.[\]]+)|\[(\d+)\]/g diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html index 095cbf65b..f6498e2db 100644 --- a/resources/admin/dashboard.html +++ b/resources/admin/dashboard.html @@ -99,6 +99,10 @@

Health

Network Health

> probes: waiting for snapshot…

No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.
+
+

HIST Recent probe runs

+
+
diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 115ea8454..2d85a1fee 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -122,6 +122,9 @@ nip66: # Public WebSocket URLs to probe. Empty list defaults to info.relay_url. targets: [] dnsCacheTtlSeconds: 300 + historyRetentionRuns: 168 + # Optional max age in hours for probe history; 0 disables age-based trimming (run count only). + historyRetentionHours: 0 wot: # Web of Trust graph. When enabled, the relay builds a trust graph rooted # at seedPubkey from NIP-02 contact lists, updated in real time as kind-3 diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index f84f542cf..853810828 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -29,6 +29,8 @@ export interface ICacheAdapter { setKeyIfNotExists(key: string, value: string, expirySeconds?: number): Promise addToSortedSet(key: string, set: Record | Record[]): Promise removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise + removeRangeByRankFromSortedSet(key: string, start: number, stop: number): Promise + getSortedSetSize(key: string): Promise getRangeFromSortedSet(key: string, start: number, stop: number): Promise setKeyExpiry(key: string, expiry: number): Promise diff --git a/src/@types/relay-probe-snapshot.ts b/src/@types/relay-probe-snapshot.ts index 51f23d693..3e12df6f5 100644 --- a/src/@types/relay-probe-snapshot.ts +++ b/src/@types/relay-probe-snapshot.ts @@ -47,6 +47,11 @@ export interface RelayProbeRunSnapshot { probeContext?: RelayProbeRunContext } +export interface RelayProbeHistoryRetention { + maxRuns: number + maxAgeHours?: number +} + export interface IRelayProbeSnapshotStore { saveLatest(snapshot: RelayProbeRunSnapshot, expirySeconds?: number): Promise getLatest(): Promise diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 885ecdb11..0dcb3a744 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -356,6 +356,8 @@ export interface Nip66Settings { * Optional NIP-52 geohash describing this monitor's network vantage for kind 10166 events. */ geohash?: string + historyRetentionRuns?: number + historyRetentionHours?: number } export interface Nip05Settings { diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index 01852ea47..35c51319d 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -84,6 +84,18 @@ export class RedisAdapter implements ICacheAdapter { return this.client.zRemRangeByScore(key, min, max) } + public async removeRangeByRankFromSortedSet(key: string, start: number, stop: number): Promise { + await this.connection + logger('remove rank %d..%d from sorted set %s', start, stop, key) + return this.client.zRemRangeByRank(key, start, stop) + } + + public async getSortedSetSize(key: string): Promise { + await this.connection + logger('get sorted set size for %s', key) + return this.client.zCard(key) + } + public async getRangeFromSortedSet(key: string, min: number, max: number): Promise { await this.connection logger('get %d..%d range from sorted set %s', min, max, key) diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts index d7caab6e3..e8a7b2f44 100644 --- a/src/app/relay-monitor-worker.ts +++ b/src/app/relay-monitor-worker.ts @@ -7,7 +7,8 @@ import { shutdownMetricsTelemetry } from '../telemetry/metrics' import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets' import { deriveRelayProbeRunStatus, serializeProbeResults } from '../utils/relay-probe-snapshot' import { resolvePublicProbeTargetKeys } from '../utils/relay-probe-targets' -import { getEffectiveProbeIntervalSeconds, getProbeIntervalMs } from '../utils/nip66-schedule' +import { RelayProbeHistoryStore } from '../utils/relay-probe-history' +import { getEffectiveProbeIntervalSeconds, getNip66HistoryRetention, getProbeIntervalMs } from '../utils/nip66-schedule' import { getMonitorPrivateKey } from '../utils/monitor-identity' import { runProbe } from '../utils/relay-probe' import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types' @@ -39,6 +40,7 @@ export class RelayMonitorWorker implements IRunnable { private readonly snapshotStore: IRelayProbeSnapshotStore, private readonly probeRunner: RunProbeFn = runProbe, private readonly eventPublisher?: INip66EventPublisher, + private readonly historyStore?: RelayProbeHistoryStore, ) { this.process .on('SIGINT', this.onExit.bind(this)) @@ -136,6 +138,13 @@ export class RelayMonitorWorker implements IRunnable { const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2 await this.snapshotStore.saveLatest(snapshot, expirySeconds) + if (this.historyStore) { + try { + await this.historyStore.append(snapshot, getNip66HistoryRetention(currentSettings)) + } catch (error) { + logger.error('failed to save probe history: %o', error) + } + } logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status) if (this.eventPublisher) { diff --git a/src/controllers/admin/get-network-health-history-controller.ts b/src/controllers/admin/get-network-health-history-controller.ts new file mode 100644 index 000000000..c52e6f5a6 --- /dev/null +++ b/src/controllers/admin/get-network-health-history-controller.ts @@ -0,0 +1,49 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { Settings } from '../../@types/settings' +import { + buildNetworkHealthMismatchContext, + enrichSnapshotWithMismatches, +} from '../../utils/network-health-mismatches' +import { getNip66HistoryRetention } from '../../utils/nip66-schedule' +import { MAX_PROBE_HISTORY_API_LIMIT, RelayProbeHistoryStore } from '../../utils/relay-probe-history' +import { loadMergedSettings } from '../../utils/settings-config' + +export const DEFAULT_NETWORK_HEALTH_HISTORY_LIMIT = 24 + +export const parseNetworkHealthHistoryLimit = (raw: unknown): number | null => { + if (raw === undefined) { + return DEFAULT_NETWORK_HEALTH_HISTORY_LIMIT + } + + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed < 1) { + return null + } + + return Math.min(Math.floor(parsed), MAX_PROBE_HISTORY_API_LIMIT) +} + +export class GetAdminNetworkHealthHistoryController implements IController { + public constructor( + private readonly historyStore: RelayProbeHistoryStore, + private readonly getSettings: () => Settings = loadMergedSettings, + ) {} + + public async handleRequest(request: Request, response: Response): Promise { + const limit = parseNetworkHealthHistoryLimit(request.query.limit) + if (limit === null) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'limit must be a positive integer' }) + return + } + + const settings = this.getSettings() + const runs = await this.historyStore.list(limit, getNip66HistoryRetention(settings)) + const enriched = runs.map((snapshot) => + enrichSnapshotWithMismatches(snapshot, buildNetworkHealthMismatchContext(snapshot, settings)), + ) + + response.status(200).setHeader('content-type', 'application/json').send({ runs: enriched }) + } +} diff --git a/src/factories/controllers/get-admin-network-health-history-controller-factory.ts b/src/factories/controllers/get-admin-network-health-history-controller-factory.ts new file mode 100644 index 000000000..ac9146215 --- /dev/null +++ b/src/factories/controllers/get-admin-network-health-history-controller-factory.ts @@ -0,0 +1,11 @@ +import { RedisAdapter } from '../../adapters/redis-adapter' +import { IController } from '../../@types/controllers' +import { getCacheClient } from '../../cache/client' +import { GetAdminNetworkHealthHistoryController } from '../../controllers/admin/get-network-health-history-controller' +import { RelayProbeHistoryStore } from '../../utils/relay-probe-history' + +export const createGetAdminNetworkHealthHistoryController = (): IController => { + const historyStore = new RelayProbeHistoryStore(new RedisAdapter(getCacheClient())) + + return new GetAdminNetworkHealthHistoryController(historyStore) +} diff --git a/src/factories/relay-monitor-worker-factory.ts b/src/factories/relay-monitor-worker-factory.ts index d2f0afe2e..0dae13289 100644 --- a/src/factories/relay-monitor-worker-factory.ts +++ b/src/factories/relay-monitor-worker-factory.ts @@ -5,14 +5,16 @@ import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' import { createEventRepository } from './event-repository-factory' import { createSettings } from './settings-factory' import { Nip66EventPublisher } from '../services/nip66-event-publisher' +import { RelayProbeHistoryStore } from '../utils/relay-probe-history' import { RelayProbeSnapshotStore } from '../utils/relay-probe-snapshot' import { runProbe } from '../utils/relay-probe' export const relayMonitorWorkerFactory = () => { const cache = new RedisAdapter(getCacheClient()) const snapshotStore = new RelayProbeSnapshotStore(cache) + const historyStore = new RelayProbeHistoryStore(cache) const eventRepository = createEventRepository(getMasterDbClient(), getReadReplicaDbClient()) const eventPublisher = new Nip66EventPublisher(eventRepository, cache) - return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher) + return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher, historyStore) } diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 0f73629f0..3f2740f27 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -6,6 +6,7 @@ import { createPatchAdminNotificationsController } from '../../factories/control import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' import { createGetAdminNetworkHealthController } from '../../factories/controllers/get-admin-network-health-controller-factory' +import { createGetAdminNetworkHealthHistoryController } from '../../factories/controllers/get-admin-network-health-history-controller-factory' import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' import { createGetAdminSettingsBackupsController } from '../../factories/controllers/get-admin-settings-backups-controller-factory' import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory' @@ -67,6 +68,12 @@ router.get( adminAuthMiddleware, withAdminController(createGetAdminNetworkHealthController), ) +router.get( + '/network-health/history', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminNetworkHealthHistoryController), +) router.get( '/settings', adminRateLimitMiddleware, diff --git a/src/utils/nip66-schedule.ts b/src/utils/nip66-schedule.ts index aaf7aff25..2c2cf416b 100644 --- a/src/utils/nip66-schedule.ts +++ b/src/utils/nip66-schedule.ts @@ -2,6 +2,7 @@ import { Settings } from '../@types/settings' export const DEFAULT_PROBE_INTERVAL_SECONDS = 3600 export const MIN_PROBE_INTERVAL_SECONDS = 60 +export const DEFAULT_HISTORY_RETENTION_RUNS = 168 export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => { const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS @@ -12,3 +13,11 @@ export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => export const getProbeIntervalMs = (settings: Settings): number => { return getEffectiveProbeIntervalSeconds(settings) * 1000 } + +export const getNip66HistoryRetention = (settings: Settings) => { + const maxAgeHours = settings.nip66?.historyRetentionHours + return { + maxRuns: settings.nip66?.historyRetentionRuns ?? DEFAULT_HISTORY_RETENTION_RUNS, + maxAgeHours: typeof maxAgeHours === 'number' && maxAgeHours > 0 ? maxAgeHours : undefined, + } +} diff --git a/src/utils/relay-probe-history.ts b/src/utils/relay-probe-history.ts new file mode 100644 index 000000000..30e4db54f --- /dev/null +++ b/src/utils/relay-probe-history.ts @@ -0,0 +1,57 @@ +import { ICacheAdapter } from '../@types/adapters' +import { RelayProbeHistoryRetention, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot' + +export const RELAY_PROBE_HISTORY_KEY = 'nip66:snapshot:history' +export const MAX_PROBE_HISTORY_API_LIMIT = 500 + +const jsonReplacer = (_key: string, value: unknown): unknown => { + if (value instanceof Date) { + return value.toISOString() + } + + return value +} + +export class RelayProbeHistoryStore { + public constructor(private readonly cache: ICacheAdapter) {} + + public async append(snapshot: RelayProbeRunSnapshot, retention: RelayProbeHistoryRetention): Promise { + const score = Date.parse(snapshot.runAt) + if (!Number.isFinite(score)) { + return + } + + const payload = JSON.stringify(snapshot, jsonReplacer) + await this.cache.addToSortedSet(RELAY_PROBE_HISTORY_KEY, { [payload]: String(score) }) + await this.trim(retention) + } + + public async list(limit: number, retention?: RelayProbeHistoryRetention): Promise { + if (retention) { + await this.trim(retention) + } + + const clamped = Math.min(Math.max(Math.floor(limit), 1), MAX_PROBE_HISTORY_API_LIMIT) + const raw = await this.cache.getRangeFromSortedSet(RELAY_PROBE_HISTORY_KEY, -clamped, -1) + + return raw + .map((entry) => JSON.parse(entry) as RelayProbeRunSnapshot) + .reverse() + } + + private async trim(retention: RelayProbeHistoryRetention): Promise { + const maxRuns = Math.max(1, Math.floor(retention.maxRuns)) + // Drop oldest ranks without ZCARD: safe under concurrent trim (idempotent excess removal). + await this.cache.removeRangeByRankFromSortedSet( + RELAY_PROBE_HISTORY_KEY, + 0, + -(maxRuns + 1), + ) + + const maxAgeHours = retention.maxAgeHours + if (typeof maxAgeHours === 'number' && maxAgeHours > 0) { + const minScore = Date.now() - maxAgeHours * 3600 * 1000 + await this.cache.removeRangeByScoreFromSortedSet(RELAY_PROBE_HISTORY_KEY, 0, minScore) + } + } +} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 93fb91498..e30453f4e 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -621,6 +621,20 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => { }) } + const nip66 = settings.nip66 + if (nip66?.historyRetentionRuns !== undefined && !(nip66.historyRetentionRuns >= 1)) { + issues.push({ path: 'nip66.historyRetentionRuns', message: 'historyRetentionRuns must be at least 1' }) + } + if ( + nip66?.historyRetentionHours !== undefined && + !(nip66.historyRetentionHours === 0 || nip66.historyRetentionHours >= 1) + ) { + issues.push({ + path: 'nip66.historyRetentionHours', + message: 'historyRetentionHours must be 0 (disabled) or at least 1', + }) + } + const pow = settings.limits?.event?.pow if (pow?.enabled) { if (!(pow.floorBits >= 0) || !(pow.floorBits <= pow.ceilingBits)) { diff --git a/test/unit/adapters/redis-adapter.spec.ts b/test/unit/adapters/redis-adapter.spec.ts index 45fb8462b..6a5ef10c1 100644 --- a/test/unit/adapters/redis-adapter.spec.ts +++ b/test/unit/adapters/redis-adapter.spec.ts @@ -29,6 +29,8 @@ describe('RedisAdapter', () => { get: sandbox.stub(), set: sandbox.stub(), zRemRangeByScore: sandbox.stub(), + zRemRangeByRank: sandbox.stub(), + zCard: sandbox.stub(), zRange: sandbox.stub(), expire: sandbox.stub(), zAdd: sandbox.stub(), @@ -163,6 +165,28 @@ describe('RedisAdapter', () => { }) }) + describe('removeRangeByRankFromSortedSet', () => { + it('calls client.zRemRangeByRank with correct arguments', async () => { + client.zRemRangeByRank.resolves(2) + + const result = await adapter.removeRangeByRankFromSortedSet('sorted-key', 0, 5) + + expect(client.zRemRangeByRank).to.have.been.calledOnceWithExactly('sorted-key', 0, 5) + expect(result).to.equal(2) + }) + }) + + describe('getSortedSetSize', () => { + it('calls client.zCard with the key', async () => { + client.zCard.resolves(4) + + const result = await adapter.getSortedSetSize('sorted-key') + + expect(client.zCard).to.have.been.calledOnceWithExactly('sorted-key') + expect(result).to.equal(4) + }) + }) + describe('getRangeFromSortedSet', () => { it('calls client.zRange with correct arguments', async () => { client.zRange.resolves(['a', 'b', 'c']) diff --git a/test/unit/app/relay-monitor-worker.spec.ts b/test/unit/app/relay-monitor-worker.spec.ts index 7d4df9b2d..a8c2d84e7 100644 --- a/test/unit/app/relay-monitor-worker.spec.ts +++ b/test/unit/app/relay-monitor-worker.spec.ts @@ -28,6 +28,10 @@ describe('RelayMonitorWorker', () => { getLatest: Sinon.SinonStub } let probeRunner: Sinon.SinonStub + let historyStore: { + append: Sinon.SinonStub + list: Sinon.SinonStub + } const probeResult = (): ProbeResult => ({ @@ -77,9 +81,20 @@ describe('RelayMonitorWorker', () => { saveLatest: sandbox.stub().resolves(), getLatest: sandbox.stub().resolves(null), } + historyStore = { + append: sandbox.stub().resolves(), + list: sandbox.stub().resolves([]), + } probeRunner = sandbox.stub().resolves(probeResult()) - worker = new RelayMonitorWorker(fakeProcess as unknown as NodeJS.Process, settings, snapshotStore, probeRunner) + worker = new RelayMonitorWorker( + fakeProcess as unknown as NodeJS.Process, + settings, + snapshotStore, + probeRunner, + undefined, + historyStore as any, + ) }) afterEach(() => { @@ -102,17 +117,21 @@ describe('RelayMonitorWorker', () => { worker.run() await Promise.resolve() + await Promise.resolve() expect(probeRunner).to.have.been.calledOnceWith('wss://relay.example.com', buildProbeOptions(settingsState)) expect(snapshotStore.saveLatest).to.have.been.calledOnce + expect(historyStore.append).to.have.been.calledOnce probeRunner.resetHistory() snapshotStore.saveLatest.resetHistory() + historyStore.append.resetHistory() await clock.tickAsync(60_000) expect(probeRunner).to.have.been.calledOnce expect(snapshotStore.saveLatest).to.have.been.calledOnce + expect(historyStore.append).to.have.been.calledOnce clock.restore() }) @@ -176,8 +195,33 @@ describe('RelayMonitorWorker', () => { worker.run() await Promise.resolve() await Promise.resolve() + await Promise.resolve() expect(eventPublisher.publishAfterProbe).to.have.been.calledOnce expect(eventPublisher.publishAfterProbe.firstCall.args[0].status).to.equal('ok') }) + + it('still publishes NIP-66 events when probe history append fails', async () => { + const eventPublisher = { + publishAfterProbe: sandbox.stub().resolves(), + } + + historyStore.append.rejects(new Error('redis unavailable')) + + worker = new RelayMonitorWorker( + fakeProcess as unknown as NodeJS.Process, + settings, + snapshotStore, + probeRunner, + eventPublisher, + historyStore as any, + ) + + worker.run() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(eventPublisher.publishAfterProbe).to.have.been.calledOnce + }) }) diff --git a/test/unit/controllers/admin/get-network-health-history-controller.spec.ts b/test/unit/controllers/admin/get-network-health-history-controller.spec.ts new file mode 100644 index 000000000..bcf86cd14 --- /dev/null +++ b/test/unit/controllers/admin/get-network-health-history-controller.spec.ts @@ -0,0 +1,82 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { Settings } from '../../../../src/@types/settings' +import { RelayProbeRunSnapshot } from '../../../../src/@types/relay-probe-snapshot' +import { + GetAdminNetworkHealthHistoryController, + parseNetworkHealthHistoryLimit, +} from '../../../../src/controllers/admin/get-network-health-history-controller' +import { RelayProbeHistoryStore } from '../../../../src/utils/relay-probe-history' + +chai.use(sinonChai) + +const { expect } = chai + +describe('GetAdminNetworkHealthHistoryController', () => { + let historyStore: Sinon.SinonStubbedInstance + let controller: GetAdminNetworkHealthHistoryController + let response: { + status: Sinon.SinonStub + setHeader: Sinon.SinonStub + send: Sinon.SinonStub + } + + const settings = { + info: { relay_url: 'wss://relay.example.com/' }, + nip66: { enabled: true, targets: [] }, + } as Settings + + const snapshot: RelayProbeRunSnapshot = { + runAt: '2026-01-01T00:00:00.000Z', + targets: ['wss://relay.example.com'], + results: [], + status: 'ok', + } + + beforeEach(() => { + historyStore = { + append: Sinon.stub(), + list: Sinon.stub().resolves([snapshot]), + } as any + + controller = new GetAdminNetworkHealthHistoryController(historyStore, () => settings) + + response = { + status: Sinon.stub().returnsThis(), + setHeader: Sinon.stub().returnsThis(), + send: Sinon.stub().returnsThis(), + } + }) + + it('returns bounded probe history newest first', async () => { + await controller.handleRequest({ query: { limit: '5' } } as any, response as any) + + expect(historyStore.list).to.have.been.calledOnce + expect(historyStore.list.firstCall.args[0]).to.equal(5) + expect(historyStore.list.firstCall.args[1]).to.deep.equal({ maxRuns: 168, maxAgeHours: undefined }) + expect(response.status).to.have.been.calledOnceWithExactly(200) + const payload = response.send.firstCall.args[0] as { runs: RelayProbeRunSnapshot[] } + expect(payload.runs).to.have.length(1) + expect(payload.runs[0].runAt).to.equal(snapshot.runAt) + }) + + it('rejects invalid limit query values', async () => { + await controller.handleRequest({ query: { limit: '0' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(historyStore.list).not.to.have.been.called + }) +}) + +describe('parseNetworkHealthHistoryLimit', () => { + it('defaults when limit is omitted', () => { + expect(parseNetworkHealthHistoryLimit(undefined)).to.equal(24) + }) + + it('rejects non-positive limits', () => { + expect(parseNetworkHealthHistoryLimit('abc')).to.equal(null) + expect(parseNetworkHealthHistoryLimit(0)).to.equal(null) + }) +}) diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts index de96ae294..82ba4f77b 100644 --- a/test/unit/routes/admin.spec.ts +++ b/test/unit/routes/admin.spec.ts @@ -204,14 +204,18 @@ describe('admin router', () => { const healthResponse = await axios.get(`${baseUrl}/health`, { validateStatus: () => true }) const metricsResponse = await axios.get(`${baseUrl}/metrics`, { validateStatus: () => true }) const networkHealthResponse = await axios.get(`${baseUrl}/network-health`, { validateStatus: () => true }) + const networkHealthHistoryResponse = await axios.get(`${baseUrl}/network-health/history`, { + validateStatus: () => true, + }) const notificationsResponse = await axios.get(`${baseUrl}/notifications`, { validateStatus: () => true }) expect(sessionResponse.status).to.equal(401) expect(healthResponse.status).to.equal(401) expect(metricsResponse.status).to.equal(401) expect(networkHealthResponse.status).to.equal(401) + expect(networkHealthHistoryResponse.status).to.equal(401) expect(notificationsResponse.status).to.equal(401) - expect(rateLimiterMiddlewareStub.callCount).to.equal(5) + expect(rateLimiterMiddlewareStub.callCount).to.equal(6) }) it('authenticates a protected route with a signed NIP-98 event', async () => { diff --git a/test/unit/utils/nip66-schedule.spec.ts b/test/unit/utils/nip66-schedule.spec.ts index 522a07751..d7b759f86 100644 --- a/test/unit/utils/nip66-schedule.spec.ts +++ b/test/unit/utils/nip66-schedule.spec.ts @@ -2,7 +2,9 @@ import { expect } from 'chai' import { Settings } from '../../../src/@types/settings' import { + DEFAULT_HISTORY_RETENTION_RUNS, getEffectiveProbeIntervalSeconds, + getNip66HistoryRetention, getProbeIntervalMs, MIN_PROBE_INTERVAL_SECONDS, } from '../../../src/utils/nip66-schedule' @@ -22,4 +24,23 @@ describe('nip66-schedule', () => { expect(getEffectiveProbeIntervalSeconds(settings(120))).to.equal(120) expect(getProbeIntervalMs(settings(120))).to.equal(120_000) }) + + it('derives history retention defaults and overrides', () => { + expect(getNip66HistoryRetention({} as Settings)).to.deep.equal({ + maxRuns: DEFAULT_HISTORY_RETENTION_RUNS, + maxAgeHours: undefined, + }) + + expect( + getNip66HistoryRetention({ + nip66: { historyRetentionRuns: 48, historyRetentionHours: 72 }, + } as Settings), + ).to.deep.equal({ maxRuns: 48, maxAgeHours: 72 }) + + expect( + getNip66HistoryRetention({ + nip66: { historyRetentionHours: 0 }, + } as Settings), + ).to.deep.equal({ maxRuns: DEFAULT_HISTORY_RETENTION_RUNS, maxAgeHours: undefined }) + }) }) diff --git a/test/unit/utils/relay-probe-history.spec.ts b/test/unit/utils/relay-probe-history.spec.ts new file mode 100644 index 000000000..a7407a92c --- /dev/null +++ b/test/unit/utils/relay-probe-history.spec.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { RelayProbeRunSnapshot } from '../../../src/@types/relay-probe-snapshot' +import { + RELAY_PROBE_HISTORY_KEY, + RelayProbeHistoryStore, +} from '../../../src/utils/relay-probe-history' +import { serializeProbeResults } from '../../../src/utils/relay-probe-snapshot' +import { ProbeResult } from '../../../src/utils/relay-probe/types' + +describe('relay-probe-history', () => { + let cache: { + addToSortedSet: Sinon.SinonStub + getRangeFromSortedSet: Sinon.SinonStub + getSortedSetSize: Sinon.SinonStub + removeRangeByRankFromSortedSet: Sinon.SinonStub + removeRangeByScoreFromSortedSet: Sinon.SinonStub + } + let store: RelayProbeHistoryStore + + const sampleResult = (wsStatus: 'ok' | 'error'): ProbeResult => + ({ + target: { + relayUrl: 'wss://relay.example.com', + hostname: 'relay.example.com', + networkType: 'clearnet', + httpOrigin: 'https://relay.example.com', + nip11Url: 'https://relay.example.com/.well-known/nostr.json', + wsUrl: 'wss://relay.example.com', + }, + checkedAt: new Date('2026-01-01T00:00:00.000Z'), + dns: { status: 'ok', durationMs: 1 }, + tls: { status: 'ok', durationMs: 1 }, + wsRtt: { status: wsStatus, durationMs: 1 }, + nip11: { status: 'ok', durationMs: 1 }, + }) as ProbeResult + + beforeEach(() => { + cache = { + addToSortedSet: Sinon.stub().resolves(1), + getRangeFromSortedSet: Sinon.stub().resolves([]), + getSortedSetSize: Sinon.stub().resolves(0), + removeRangeByRankFromSortedSet: Sinon.stub().resolves(0), + removeRangeByScoreFromSortedSet: Sinon.stub().resolves(0), + } + + store = new RelayProbeHistoryStore(cache as any) + }) + + it('appends probe runs to bounded history', async () => { + const snapshot: RelayProbeRunSnapshot = { + runAt: '2026-01-01T00:00:00.000Z', + targets: ['wss://relay.example.com'], + results: serializeProbeResults([sampleResult('ok')]), + status: 'ok', + } + + await store.append(snapshot, { maxRuns: 1 }) + + expect(cache.addToSortedSet).to.have.been.calledOnce + expect(cache.removeRangeByRankFromSortedSet).to.have.been.calledOnceWith( + RELAY_PROBE_HISTORY_KEY, + 0, + -2, + ) + }) + + it('returns the newest history entries up to the requested limit', async () => { + const older: RelayProbeRunSnapshot = { + runAt: '2026-01-01T00:00:00.000Z', + targets: [], + results: [], + status: 'partial', + } + const newer: RelayProbeRunSnapshot = { + runAt: '2026-01-02T00:00:00.000Z', + targets: [], + results: [], + status: 'ok', + } + + cache.getRangeFromSortedSet.resolves([JSON.stringify(older), JSON.stringify(newer)]) + + const history = await store.list(2) + + expect(cache.getRangeFromSortedSet).to.have.been.calledOnceWith(RELAY_PROBE_HISTORY_KEY, -2, -1) + expect(history.map((entry) => entry.runAt)).to.deep.equal([newer.runAt, older.runAt]) + }) + + it('trims expired runs when listing with an age retention limit', async () => { + cache.getRangeFromSortedSet.resolves([]) + + await store.list(10, { maxRuns: 168, maxAgeHours: 24 }) + + expect(cache.removeRangeByScoreFromSortedSet).to.have.been.calledOnce + const [key, minScore, maxScore] = cache.removeRangeByScoreFromSortedSet.firstCall.args + expect(key).to.equal(RELAY_PROBE_HISTORY_KEY) + expect(minScore).to.equal(0) + expect(maxScore).to.be.a('number') + expect(maxScore).to.be.lessThan(Date.now()) + }) +}) diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index d9179f35d..efca05716 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -98,6 +98,17 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path === 'network')).to.equal(true) }) + it('rejects invalid nip66 history retention settings', () => { + const issues = validateSettings({ + info: { relay_url: 'wss://test.relay', name: 'test' }, + network: {}, + nip66: { historyRetentionRuns: 0, historyRetentionHours: 0 }, + } as any) + + expect(issues.some((issue) => issue.path === 'nip66.historyRetentionRuns')).to.equal(true) + expect(issues.some((issue) => issue.path === 'nip66.historyRetentionHours')).to.equal(false) + }) + describe('rate limit period validation', () => { const baseSettings = () => ({ diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index 7cbab5f03..72020ae02 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -277,6 +277,8 @@ describe('SettingsStatic', () => { expect(defaults).to.have.nested.property('nip66.timeouts.nip11Ms', 10_000) expect(defaults).to.have.nested.property('nip66.targets').that.deep.equals([]) expect(defaults).to.have.nested.property('nip66.dnsCacheTtlSeconds', 300) + expect(defaults).to.have.nested.property('nip66.historyRetentionRuns', 168) + expect(defaults).to.have.nested.property('nip66.historyRetentionHours', 0) }) it('user config nip66 block overrides defaults', () => {