Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/admin-network-health-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

Add bounded NIP-66 probe history API and Network Health dashboard timeline
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
24 changes: 24 additions & 0 deletions resources/admin/assets/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
100 changes: 99 additions & 1 deletion resources/admin/assets/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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']

Expand Down Expand Up @@ -360,6 +366,7 @@

startMetricsStream()
void refreshNetworkHealth()
void refreshNetworkHealthHistory()
startNetworkHealthPolling()
}

Expand All @@ -374,6 +381,7 @@
stopNetworkHealthPolling()
networkHealthPollTimer = setInterval(() => {
void refreshNetworkHealth()
void refreshNetworkHealthHistory()
}, networkHealthPollIntervalMs)
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions resources/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ <h2 class="section-title">Health</h2>
<h2 class="section-title">Network Health</h2>
<p id="network-health-sync" class="sync-line mb-3"><span class="prompt-char">&gt;</span> probes: waiting for snapshot…</p>
<div id="network-health-empty" class="panel-card admin-muted small">No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.</div>
<div id="network-health-timeline-wrap" class="mb-3 d-none">
<p class="metric-label mb-2"><span class="module-code">HIST</span> Recent probe runs</p>
<div id="network-health-timeline" class="network-health-timeline" role="list" aria-label="Recent probe runs"></div>
</div>
<div id="network-health-summary" class="row g-2 mb-3 d-none">
<div class="col-6 col-md-3">
<div class="metric-card h-100">
Expand Down
3 changes: 3 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ nip66:
# Public WebSocket URLs to probe. Empty list defaults to info.relay_url.
targets: []
dnsCacheTtlSeconds: 300
historyRetentionRuns: 168
Comment thread
Ferryx349 marked this conversation as resolved.
# 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
Expand Down
2 changes: 2 additions & 0 deletions src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ICacheAdapter {
setKeyIfNotExists(key: string, value: string, expirySeconds?: number): Promise<boolean>
addToSortedSet(key: string, set: Record<string, string> | Record<string, string>[]): Promise<number>
removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise<number>
removeRangeByRankFromSortedSet(key: string, start: number, stop: number): Promise<number>
getSortedSetSize(key: string): Promise<number>
getRangeFromSortedSet(key: string, start: number, stop: number): Promise<string[]>
setKeyExpiry(key: string, expiry: number): Promise<void>

Expand Down
5 changes: 5 additions & 0 deletions src/@types/relay-probe-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
getLatest(): Promise<RelayProbeRunSnapshot | null>
Expand Down
2 changes: 2 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src/adapters/redis-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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<number> {
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<string[]> {
await this.connection
logger('get %d..%d range from sorted set %s', min, max, key)
Expand Down
11 changes: 10 additions & 1 deletion src/app/relay-monitor-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
}
Comment thread
Ferryx349 marked this conversation as resolved.
logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status)

if (this.eventPublisher) {
Expand Down
49 changes: 49 additions & 0 deletions src/controllers/admin/get-network-health-history-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion src/factories/relay-monitor-worker-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading