Skip to content

Commit ff43cd3

Browse files
authored
feat(admin): add Network Health panel to observability dashboard (#764)
* feat(nip66): publish kind 30166 and 10166 relay health events After each probe run, sign and store NIP-66 relay discovery and monitor announcement events from the monitor identity, bootstrap kind 0/10002 on first run, and persist via the parameterized replaceable event path. Fixes #696 * test(nip66): query event_tags table in 30166 integration assertion Knex already parses jsonb event_tags as an array; JSON.parse on that value caused CI failures. Assert the d tag via the event_tags table instead. * feat(admin): add GET /admin/network-health endpoint * feat(nip66): publish kind 30166 and 10166 relay health events After each probe run, sign and store NIP-66 relay discovery and monitor announcement events from the monitor identity, bootstrap kind 0/10002 on first run, and persist via the parameterized replaceable event path. Fixes #696 * test(nip66): query event_tags table in 30166 integration assertion Knex already parses jsonb event_tags as an array; JSON.parse on that value caused CI failures. Assert the d tag via the event_tags table instead. * feat(admin): add GET /admin/network-health endpoint * feat(admin): add Network Health panel to observability dashboard Render NIP-66 probe snapshots in the admin Metrics view with per-target DNS/TLS/WS/NIP-11 cards and 60s polling. * fix(admin): remove unused network health DOM refs CodeQL flagged unused getElementById bindings; run status and target count are updated via setStatusText/setMetricValue instead. * fix(admin): address review feedback Broadcast newly persisted NIP-66 events, prefer relay_url in bootstrap relay list, clamp frequency tags to the worker minimum, add bootstrap TTL, and complete Network Health dashboard coverage for issue #698.
1 parent 7c2875e commit ff43cd3

13 files changed

Lines changed: 504 additions & 80 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat(admin): add Network Health panel to observability dashboard
6+
7+
Adds a dashboard section that renders the latest NIP-66 probe snapshot with per-target DNS, TLS, WebSocket RTT, and NIP-11 status.

‎resources/admin/assets/dashboard.css‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,71 @@
754754
}
755755
}
756756

757+
.network-health-results {
758+
display: grid;
759+
gap: 0.75rem;
760+
}
761+
762+
.network-health-target {
763+
background: var(--panel);
764+
border: 1px solid var(--panel-border);
765+
padding: 0.85rem 1rem;
766+
}
767+
768+
.network-health-target-header {
769+
align-items: flex-start;
770+
display: flex;
771+
gap: 0.65rem;
772+
justify-content: space-between;
773+
margin-bottom: 0.65rem;
774+
}
775+
776+
.network-health-target-url {
777+
color: var(--text);
778+
flex: 1;
779+
font-size: 0.85rem;
780+
font-weight: 600;
781+
margin-bottom: 0;
782+
word-break: break-all;
783+
}
784+
785+
.network-health-network-type {
786+
background: var(--panel-border);
787+
border: 1px solid var(--panel-border);
788+
color: var(--label);
789+
font-size: 0.62rem;
790+
font-weight: 700;
791+
letter-spacing: 0.06em;
792+
padding: 0.15rem 0.4rem;
793+
text-transform: uppercase;
794+
white-space: nowrap;
795+
}
796+
797+
.network-health-checks {
798+
display: grid;
799+
gap: 0.45rem;
800+
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
801+
}
802+
803+
.network-health-check {
804+
border: 1px solid var(--panel-border);
805+
padding: 0.45rem 0.55rem;
806+
}
807+
808+
.network-health-check-label {
809+
color: var(--label);
810+
font-size: 0.65rem;
811+
font-weight: 600;
812+
letter-spacing: 0.06em;
813+
margin-bottom: 0.15rem;
814+
text-transform: uppercase;
815+
}
816+
817+
.network-health-check-value {
818+
font-size: 0.78rem;
819+
margin-bottom: 0;
820+
}
821+
757822
@media (max-width: 768px) {
758823
.admin-dashboard .metric-value {
759824
font-size: 1rem;

‎resources/admin/assets/dashboard.js‎

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
const settingsDiffContent = document.getElementById('settings-diff-content')
3636
const settingsDiffSummary = document.getElementById('settings-diff-summary')
3737
const dashboardViews = document.querySelectorAll('.dashboard-view')
38+
const networkHealthSync = document.getElementById('network-health-sync')
39+
const networkHealthEmpty = document.getElementById('network-health-empty')
40+
const networkHealthSummary = document.getElementById('network-health-summary')
41+
const networkHealthResults = document.getElementById('network-health-results')
42+
const networkHealthRunAt = document.getElementById('network-health-run-at')
3843

3944
let settingsLoaded = false
4045
let settingsLoading = false
@@ -52,6 +57,8 @@
5257
let relativeTimeTimer
5358
let staleCheckTimer
5459
const staleThresholdMs = 15000
60+
let networkHealthPollTimer
61+
const networkHealthPollIntervalMs = 60000
5562

5663
const statusClasses = ['status-ok', 'status-degraded', 'status-unavailable', 'status-down', 'status-no-data']
5764

@@ -81,6 +88,16 @@
8188
parseError: '[ERR]',
8289
reconnect: '[RETRY]',
8390
},
91+
probeRun: {
92+
ok: '[OK]',
93+
partial: '[WARN]',
94+
failed: '[FAULT]',
95+
},
96+
probeCheck: {
97+
ok: '[OK]',
98+
error: '[ERR]',
99+
skipped: '[SKIP]',
100+
},
84101
}
85102

86103
const getTheme = () => {
@@ -300,6 +317,7 @@
300317

301318
const showLogin = () => {
302319
stopMetricsStream()
320+
stopNetworkHealthPolling()
303321
setNavOpen(false)
304322
loginPanel.classList.remove('d-none')
305323
dashboardPanel.classList.add('d-none')
@@ -341,6 +359,230 @@
341359
})
342360

343361
startMetricsStream()
362+
void refreshNetworkHealth()
363+
startNetworkHealthPolling()
364+
}
365+
366+
const stopNetworkHealthPolling = () => {
367+
if (networkHealthPollTimer) {
368+
clearInterval(networkHealthPollTimer)
369+
networkHealthPollTimer = undefined
370+
}
371+
}
372+
373+
const startNetworkHealthPolling = () => {
374+
stopNetworkHealthPolling()
375+
networkHealthPollTimer = setInterval(() => {
376+
void refreshNetworkHealth()
377+
}, networkHealthPollIntervalMs)
378+
}
379+
380+
const setNetworkHealthSyncLine = (message) => {
381+
if (!networkHealthSync) {
382+
return
383+
}
384+
385+
networkHealthSync.innerHTML = `<span class="prompt-char">&gt;</span> probes: ${message}`
386+
}
387+
388+
const probeCheckStatusClass = (status, options = {}) => {
389+
if (status === 'ok') {
390+
if (typeof options.tlsDaysUntilExpiry === 'number' && options.tlsDaysUntilExpiry < 14) {
391+
return 'status-degraded'
392+
}
393+
394+
return 'status-ok'
395+
}
396+
if (status === 'error') {
397+
return 'status-down'
398+
}
399+
400+
return 'status-no-data'
401+
}
402+
403+
const formatProbeCheckDetail = (check, formatter, options = {}) => {
404+
const label = statusLabels.probeCheck[check?.status] ?? statusLabels.probeCheck.skipped
405+
const detail = typeof formatter === 'function' && check?.status === 'ok' ? formatter(check.data) : check?.error
406+
const className = probeCheckStatusClass(check?.status, options)
407+
408+
return {
409+
label,
410+
className,
411+
detail: detail ? String(detail) : '',
412+
}
413+
}
414+
415+
const renderNetworkHealthSnapshot = (snapshot) => {
416+
if (!networkHealthEmpty || !networkHealthSummary || !networkHealthResults) {
417+
return
418+
}
419+
420+
if (!snapshot) {
421+
networkHealthEmpty.classList.remove('d-none')
422+
networkHealthSummary.classList.add('d-none')
423+
networkHealthResults.classList.add('d-none')
424+
networkHealthResults.replaceChildren()
425+
setNetworkHealthSyncLine('no probe snapshot available')
426+
return
427+
}
428+
429+
networkHealthEmpty.classList.add('d-none')
430+
networkHealthSummary.classList.remove('d-none')
431+
networkHealthResults.classList.remove('d-none')
432+
433+
const runStatus = snapshot.status ?? 'failed'
434+
const runStatusClass =
435+
runStatus === 'ok' ? 'status-ok' : runStatus === 'partial' ? 'status-degraded' : 'status-down'
436+
setStatusText(
437+
'network-health-run-status',
438+
statusLabels.probeRun[runStatus] ?? statusLabels.probeRun.failed,
439+
runStatusClass,
440+
)
441+
setMetricValue('network-health-target-count', Array.isArray(snapshot.results) ? snapshot.results.length : 0)
442+
443+
const runAtMs = Date.parse(snapshot.runAt)
444+
if (Number.isFinite(runAtMs)) {
445+
networkHealthRunAt.innerHTML = `<span class="metric-number">${new Date(runAtMs).toISOString()}</span>`
446+
setNetworkHealthSyncLine(`last updated ${formatRelativeTime(runAtMs)}`)
447+
} else {
448+
networkHealthRunAt.innerHTML = '<span class="metric-number">—</span>'
449+
setNetworkHealthSyncLine('snapshot received')
450+
}
451+
452+
networkHealthResults.replaceChildren()
453+
454+
if (!Array.isArray(snapshot.results) || snapshot.results.length === 0) {
455+
const empty = document.createElement('p')
456+
empty.className = 'admin-muted small mb-0'
457+
empty.textContent = 'Probe run completed with no target results.'
458+
networkHealthResults.appendChild(empty)
459+
return
460+
}
461+
462+
snapshot.results.forEach((result) => {
463+
const card = document.createElement('article')
464+
card.className = 'network-health-target'
465+
466+
const header = document.createElement('div')
467+
header.className = 'network-health-target-header'
468+
469+
const title = document.createElement('p')
470+
title.className = 'network-health-target-url mb-0'
471+
title.textContent = result?.target?.relayUrl ?? result?.target?.wsUrl ?? 'Unknown target'
472+
header.appendChild(title)
473+
474+
const networkType = result?.target?.networkType
475+
if (networkType) {
476+
const badge = document.createElement('span')
477+
badge.className = 'network-health-network-type'
478+
badge.textContent = networkType
479+
header.appendChild(badge)
480+
}
481+
482+
card.appendChild(header)
483+
484+
const checks = document.createElement('div')
485+
checks.className = 'network-health-checks'
486+
487+
const dns = formatProbeCheckDetail(result.dns, (data) => {
488+
const records = Array.isArray(data?.records) ? data.records : []
489+
490+
if (records.length === 0) {
491+
return 'no records'
492+
}
493+
494+
const preview = records.slice(0, 3).map((record) => {
495+
const ttl = typeof record?.ttl === 'number' ? ` TTL ${record.ttl}` : ''
496+
return `${record.type} ${record.value}${ttl}`
497+
})
498+
499+
if (records.length > 3) {
500+
preview.push(`+${records.length - 3} more`)
501+
}
502+
503+
return preview.join('; ')
504+
})
505+
const tlsDaysUntilExpiry =
506+
result.tls?.status === 'ok' && typeof result.tls?.data?.daysUntilExpiry === 'number'
507+
? result.tls.data.daysUntilExpiry
508+
: undefined
509+
const tls = formatProbeCheckDetail(
510+
result.tls,
511+
(data) => {
512+
if (typeof data?.daysUntilExpiry === 'number') {
513+
return `${data.daysUntilExpiry}d remaining`
514+
}
515+
516+
return data?.issuer ?? 'valid'
517+
},
518+
{ tlsDaysUntilExpiry },
519+
)
520+
const wsRtt = formatProbeCheckDetail(result.wsRtt, (data) => `${data.rttOpenMs} ms`)
521+
const nip11 = formatProbeCheckDetail(result.nip11, (data) => {
522+
const name = data?.name ? ` ${data.name}` : ''
523+
const supportedNips = Array.isArray(data?.supportedNips) ? data.supportedNips : null
524+
const nip66Warning =
525+
supportedNips && !supportedNips.includes(66) ? ' · NIP-66 not in supported_nips' : ''
526+
527+
return `HTTP ${data.statusCode}${name}${nip66Warning}`
528+
})
529+
530+
if (
531+
result.nip11?.status === 'ok' &&
532+
Array.isArray(result.nip11?.data?.supportedNips) &&
533+
!result.nip11.data.supportedNips.includes(66)
534+
) {
535+
nip11.className = 'status-degraded'
536+
}
537+
538+
;[
539+
['DNS', dns],
540+
['TLS', tls],
541+
['WS RTT', wsRtt],
542+
['NIP-11', nip11],
543+
].forEach(([name, check]) => {
544+
const item = document.createElement('div')
545+
item.className = 'network-health-check'
546+
547+
const label = document.createElement('p')
548+
label.className = 'network-health-check-label mb-0'
549+
label.textContent = name
550+
551+
const value = document.createElement('p')
552+
value.className = `network-health-check-value ${check.className} mb-0`
553+
value.textContent = check.detail ? `${check.label} · ${check.detail}` : check.label
554+
555+
item.appendChild(label)
556+
item.appendChild(value)
557+
checks.appendChild(item)
558+
})
559+
560+
card.appendChild(checks)
561+
networkHealthResults.appendChild(card)
562+
})
563+
}
564+
565+
const refreshNetworkHealth = async () => {
566+
try {
567+
const response = await fetch(`${adminBase}/network-health`, {
568+
credentials: 'include',
569+
})
570+
571+
if (response.status === 401) {
572+
showLogin()
573+
return
574+
}
575+
576+
if (!response.ok) {
577+
setNetworkHealthSyncLine('failed to load probe snapshot')
578+
return
579+
}
580+
581+
const body = await response.json()
582+
renderNetworkHealthSnapshot(body.snapshot ?? null)
583+
} catch {
584+
setNetworkHealthSyncLine('network error while loading probes')
585+
}
344586
}
345587

346588
const parsePathTokens = (path) => {

‎resources/admin/dashboard.html‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,33 @@ <h2 class="section-title">Health</h2>
9494
</div>
9595
</section>
9696

97+
<section id="network-health-section" class="dashboard-section">
98+
<h2 class="section-title">Network Health</h2>
99+
<p id="network-health-sync" class="sync-line mb-3"><span class="prompt-char">&gt;</span> probes: waiting for snapshot…</p>
100+
<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>
101+
<div id="network-health-summary" class="row g-2 mb-3 d-none">
102+
<div class="col-6 col-md-3">
103+
<div class="metric-card h-100">
104+
<p class="metric-label"><span class="module-code">RUN</span> Probe status</p>
105+
<p id="network-health-run-status" class="metric-value status-no-data mb-0"><span class="status-led" aria-hidden="true"></span>Pending</p>
106+
</div>
107+
</div>
108+
<div class="col-6 col-md-3">
109+
<div class="metric-card h-100">
110+
<p class="metric-label"><span class="module-code">TGT</span> Targets</p>
111+
<p id="network-health-target-count" class="metric-value mb-0"><span class="metric-number">0</span></p>
112+
</div>
113+
</div>
114+
<div class="col-12 col-md-6">
115+
<div class="metric-card h-100">
116+
<p class="metric-label"><span class="module-code">TS</span> Last probe run</p>
117+
<p id="network-health-run-at" class="metric-value mb-0"><span class="metric-number">—</span></p>
118+
</div>
119+
</div>
120+
</div>
121+
<div id="network-health-results" class="network-health-results d-none" aria-live="polite"></div>
122+
</section>
123+
97124
<section class="dashboard-section">
98125
<h2 class="section-title">Throughput</h2>
99126
<div class="row g-2">

0 commit comments

Comments
 (0)