|
35 | 35 | const settingsDiffContent = document.getElementById('settings-diff-content') |
36 | 36 | const settingsDiffSummary = document.getElementById('settings-diff-summary') |
37 | 37 | 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') |
38 | 43 |
|
39 | 44 | let settingsLoaded = false |
40 | 45 | let settingsLoading = false |
|
52 | 57 | let relativeTimeTimer |
53 | 58 | let staleCheckTimer |
54 | 59 | const staleThresholdMs = 15000 |
| 60 | + let networkHealthPollTimer |
| 61 | + const networkHealthPollIntervalMs = 60000 |
55 | 62 |
|
56 | 63 | const statusClasses = ['status-ok', 'status-degraded', 'status-unavailable', 'status-down', 'status-no-data'] |
57 | 64 |
|
|
81 | 88 | parseError: '[ERR]', |
82 | 89 | reconnect: '[RETRY]', |
83 | 90 | }, |
| 91 | + probeRun: { |
| 92 | + ok: '[OK]', |
| 93 | + partial: '[WARN]', |
| 94 | + failed: '[FAULT]', |
| 95 | + }, |
| 96 | + probeCheck: { |
| 97 | + ok: '[OK]', |
| 98 | + error: '[ERR]', |
| 99 | + skipped: '[SKIP]', |
| 100 | + }, |
84 | 101 | } |
85 | 102 |
|
86 | 103 | const getTheme = () => { |
|
300 | 317 |
|
301 | 318 | const showLogin = () => { |
302 | 319 | stopMetricsStream() |
| 320 | + stopNetworkHealthPolling() |
303 | 321 | setNavOpen(false) |
304 | 322 | loginPanel.classList.remove('d-none') |
305 | 323 | dashboardPanel.classList.add('d-none') |
|
341 | 359 | }) |
342 | 360 |
|
343 | 361 | 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">></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 | + } |
344 | 586 | } |
345 | 587 |
|
346 | 588 | const parsePathTokens = (path) => { |
|
0 commit comments