diff --git a/components/profile-modal.html b/components/profile-modal.html index 9ba53b51..62583aee 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -664,6 +664,44 @@ +
+
+
+

Relay health

+

+ Monitor connectivity, latency, and errors for your configured relays. +

+
+ +
+ +

+ Telemetry is opt-in and only includes relay URLs, latency, and error counts. +

+ +

+
0, + contentLength: typeof event.content === "string" ? event.content.length : 0, + tagCount: Array.isArray(event.tags) ? event.tags.length : 0, + }; +} + +export function sanitizeDecryptError(error) { + if (!error) { + return null; + } + + const message = + typeof error.message === "string" + ? error.message + : typeof error === "string" + ? error + : ""; + + return { + name: typeof error.name === "string" ? error.name : "", + code: typeof error.code === "string" ? error.code : "", + message, + }; +} diff --git a/js/services/relayHealthService.js b/js/services/relayHealthService.js new file mode 100644 index 00000000..87ed52fc --- /dev/null +++ b/js/services/relayHealthService.js @@ -0,0 +1,236 @@ +import { devLogger, userLogger } from "../utils/logger.js"; + +const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in"; + +function resolveLogger(logger) { + if (logger && logger.dev && logger.user) { + return logger; + } + return { dev: devLogger, user: userLogger }; +} + +function nowMs() { + return typeof performance !== "undefined" && performance.now + ? performance.now() + : Date.now(); +} + +function readTelemetryOptIn() { + if (typeof window === "undefined") { + return false; + } + + try { + const stored = window.localStorage.getItem(TELEMETRY_STORAGE_KEY); + return stored === "true"; + } catch (error) { + userLogger.warn( + "[relayHealth] Failed to read telemetry preference from storage.", + error, + ); + return false; + } +} + +function writeTelemetryOptIn(nextValue) { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + TELEMETRY_STORAGE_KEY, + nextValue ? "true" : "false", + ); + } catch (error) { + userLogger.warn( + "[relayHealth] Failed to persist telemetry preference.", + error, + ); + } +} + +class RelayHealthService { + constructor({ relayManager, nostrClient, logger, telemetryEmitter } = {}) { + this.relayManager = relayManager || null; + this.nostrClient = nostrClient || null; + this.logger = resolveLogger(logger); + this.telemetryEmitter = + typeof telemetryEmitter === "function" ? telemetryEmitter : null; + this.relayStates = new Map(); + this.attachedRelays = new Set(); + this.cachedTelemetryOptIn = readTelemetryOptIn(); + } + + getTelemetryOptIn() { + return this.cachedTelemetryOptIn; + } + + setTelemetryOptIn(value) { + const normalized = Boolean(value); + this.cachedTelemetryOptIn = normalized; + writeTelemetryOptIn(normalized); + return normalized; + } + + getRelayUrls() { + if (!this.relayManager || typeof this.relayManager.getEntries !== "function") { + return []; + } + const entries = this.relayManager.getEntries(); + return entries + .map((entry) => (typeof entry?.url === "string" ? entry.url.trim() : "")) + .filter(Boolean); + } + + ensureRelayState(relayUrl) { + if (!this.relayStates.has(relayUrl)) { + this.relayStates.set(relayUrl, { + url: relayUrl, + connected: false, + lastLatencyMs: null, + errorCount: 0, + lastCheckedAt: null, + lastErrorAt: null, + }); + } + + return this.relayStates.get(relayUrl); + } + + attachRelayListeners(relayUrl, relay) { + if (!relay || typeof relay.on !== "function") { + return; + } + if (this.attachedRelays.has(relayUrl)) { + return; + } + + try { + relay.on("connect", () => { + this.recordRelaySuccess(relayUrl, null); + }); + relay.on("disconnect", () => { + this.recordRelayFailure(relayUrl, new Error("relay-disconnect")); + }); + relay.on("error", (error) => { + this.recordRelayFailure(relayUrl, error); + }); + this.attachedRelays.add(relayUrl); + } catch (error) { + this.logger.dev.warn( + "[relayHealth] Failed to attach relay listeners.", + error, + ); + } + } + + recordRelaySuccess(relayUrl, latencyMs) { + const state = this.ensureRelayState(relayUrl); + state.connected = true; + state.lastCheckedAt = Date.now(); + if (Number.isFinite(latencyMs)) { + state.lastLatencyMs = Math.max(0, Math.round(latencyMs)); + } + } + + recordRelayFailure(relayUrl, error) { + const state = this.ensureRelayState(relayUrl); + state.connected = false; + state.errorCount += 1; + state.lastCheckedAt = Date.now(); + state.lastErrorAt = Date.now(); + + this.logger.dev.warn("[relayHealth] Relay check failed.", { + relayUrl, + error, + }); + } + + async checkRelay(relayUrl) { + if (!relayUrl) { + return; + } + + if (!this.nostrClient) { + this.recordRelayFailure(relayUrl, new Error("nostr-client-unavailable")); + return; + } + + try { + if (typeof this.nostrClient.ensurePool === "function") { + await this.nostrClient.ensurePool(); + } + } catch (error) { + this.recordRelayFailure(relayUrl, error); + return; + } + + if (!this.nostrClient.pool || typeof this.nostrClient.pool.ensureRelay !== "function") { + this.recordRelayFailure(relayUrl, new Error("relay-pool-unavailable")); + return; + } + + const start = nowMs(); + try { + const relay = await this.nostrClient.pool.ensureRelay(relayUrl); + if (!relay) { + throw new Error("relay-unavailable"); + } + + this.attachRelayListeners(relayUrl, relay); + this.recordRelaySuccess(relayUrl, nowMs() - start); + } catch (error) { + this.recordRelayFailure(relayUrl, error); + } + } + + getSnapshot(relayUrls = null) { + const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls(); + return urls.map((url) => ({ ...this.ensureRelayState(url) })); + } + + async refresh(relayUrls = null) { + const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls(); + if (!urls.length) { + return []; + } + + await Promise.allSettled(urls.map((url) => this.checkRelay(url))); + const snapshot = this.getSnapshot(urls); + this.emitTelemetry(snapshot); + return snapshot; + } + + emitTelemetry(snapshot) { + if (!this.telemetryEmitter || !this.getTelemetryOptIn()) { + return; + } + + const payload = { + sampledAt: Date.now(), + relays: Array.isArray(snapshot) + ? snapshot.map((entry) => ({ + url: entry.url, + connected: Boolean(entry.connected), + latencyMs: + Number.isFinite(entry.lastLatencyMs) ? entry.lastLatencyMs : null, + errorCount: Number.isFinite(entry.errorCount) + ? entry.errorCount + : 0, + })) + : [], + }; + + try { + this.telemetryEmitter("relay_health_snapshot", payload); + } catch (error) { + this.logger.user.warn( + "[relayHealth] Failed to emit relay health telemetry.", + error, + ); + } + } +} + +export default RelayHealthService; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 948be196..9d21d8f3 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -17,6 +17,7 @@ import nostrService from "../services/nostrService.js"; import storageService from "../services/storageService.js"; import watchHistoryService from "../watchHistoryService.js"; import r2Service from "../services/r2Service.js"; +import RelayHealthService from "../services/relayHealthService.js"; import { createFeedEngine } from "../feedEngine/index.js"; import { URL_FIRST_ENABLED } from "../constants.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; @@ -426,6 +427,12 @@ export default class ApplicationBootstrap { const modalContainer = doc?.getElementById("modalContainer") || null; try { if (modalContainer) { + const relayHealthService = new RelayHealthService({ + relayManager, + nostrClient, + telemetryEmitter: (eventName, payload) => + app.emitTelemetryEvent(eventName, payload), + }); const profileModalServices = { normalizeHexPubkey: (value) => app.normalizeHexPubkey(value), safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey), @@ -480,6 +487,7 @@ export default class ApplicationBootstrap { app.describeHashtagPreferencesError(error, { fallbackMessage, }), + relayHealthService, log: (...args) => app.log(...args), closeAllMoreMenus: (options) => app.closeAllMoreMenus(options), clipboard: diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 50b7eaa7..dd37ca75 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1241,6 +1241,12 @@ export class ProfileModalController { this.relayInput = null; this.addRelayButton = null; this.restoreRelaysButton = null; + this.relayHealthPanel = null; + this.relayHealthList = null; + this.relayHealthStatus = null; + this.relayHealthRefreshButton = null; + this.relayHealthTelemetryToggle = null; + this.relayHealthRefreshPromise = null; this.profileRelayList = null; this.profileRelayInput = null; this.profileAddRelayBtn = null; @@ -1521,6 +1527,16 @@ export class ProfileModalController { this.addRelayButton = document.getElementById("addRelayBtn") || null; this.restoreRelaysButton = document.getElementById("restoreRelaysBtn") || null; + this.relayHealthPanel = + document.getElementById("relayHealthPanel") || null; + this.relayHealthList = + document.getElementById("relayHealthList") || null; + this.relayHealthStatus = + document.getElementById("relayHealthStatus") || null; + this.relayHealthRefreshButton = + document.getElementById("relayHealthRefreshBtn") || null; + this.relayHealthTelemetryToggle = + document.getElementById("relayHealthTelemetryOptIn") || null; this.subscriptionList = document.getElementById("subscriptionsList") || null; @@ -4188,6 +4204,18 @@ export class ProfileModalController { }); } + if (this.relayHealthRefreshButton instanceof HTMLElement) { + this.relayHealthRefreshButton.addEventListener("click", () => { + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "manual" }); + }); + } + + if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) { + this.relayHealthTelemetryToggle.addEventListener("change", () => { + this.handleRelayHealthTelemetryToggle(); + }); + } + if (this.addBlockedButton instanceof HTMLElement) { this.addBlockedButton.addEventListener("click", () => { void this.handleAddBlockedCreator(); @@ -5828,6 +5856,9 @@ export class ProfileModalController { if (target === "history") { void this.populateProfileWatchHistory(); + } else if (target === "relays") { + this.populateProfileRelays(); + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "pane-select" }); } else if (target === "messages") { this.resumeProfileMessages(); void this.populateProfileMessages({ reason: "pane-select" }); @@ -5949,6 +5980,166 @@ export class ProfileModalController { }); } + updateRelayHealthStatus(message = "") { + if (!this.relayHealthStatus) { + return; + } + + const text = typeof message === "string" ? message.trim() : ""; + this.relayHealthStatus.textContent = text; + } + + renderRelayHealthSnapshot(snapshot = []) { + if (!this.relayHealthList) { + return; + } + + this.relayHealthList.innerHTML = ""; + + if (!snapshot.length) { + const emptyState = document.createElement("li"); + emptyState.className = + "card border border-dashed border-surface-strong p-4 text-center text-sm text-muted"; + emptyState.textContent = "No relays configured."; + this.relayHealthList.appendChild(emptyState); + return; + } + + snapshot.forEach((entry) => { + const item = document.createElement("li"); + item.className = "card flex flex-col gap-2 p-4"; + + const header = document.createElement("div"); + header.className = "flex items-center justify-between gap-3"; + + const urlEl = document.createElement("p"); + urlEl.className = "text-sm font-medium text-primary break-all"; + urlEl.textContent = entry.url; + + const connection = document.createElement("span"); + const connected = Boolean(entry.connected); + connection.className = `text-xs font-semibold ${ + connected ? "text-status-success" : "text-status-danger" + }`; + connection.textContent = connected ? "Connected" : "Unavailable"; + + header.appendChild(urlEl); + header.appendChild(connection); + + const details = document.createElement("div"); + details.className = "grid grid-cols-2 gap-3 text-xs text-muted sm:grid-cols-3"; + + const latency = document.createElement("div"); + latency.className = "flex items-center justify-between gap-2"; + const latencyLabel = document.createElement("span"); + latencyLabel.textContent = "Latency"; + const latencyValue = document.createElement("span"); + latencyValue.className = "text-text"; + latencyValue.textContent = Number.isFinite(entry.lastLatencyMs) + ? `${entry.lastLatencyMs} ms` + : "—"; + latency.appendChild(latencyLabel); + latency.appendChild(latencyValue); + + const errors = document.createElement("div"); + errors.className = "flex items-center justify-between gap-2"; + const errorsLabel = document.createElement("span"); + errorsLabel.textContent = "Errors"; + const errorsValue = document.createElement("span"); + errorsValue.className = "text-text"; + errorsValue.textContent = Number.isFinite(entry.errorCount) + ? `${entry.errorCount}` + : "0"; + errors.appendChild(errorsLabel); + errors.appendChild(errorsValue); + + const checks = document.createElement("div"); + checks.className = "flex items-center justify-between gap-2"; + const checksLabel = document.createElement("span"); + checksLabel.textContent = "Checked"; + const checksValue = document.createElement("span"); + checksValue.className = "text-text"; + if (Number.isFinite(entry.lastCheckedAt) && entry.lastCheckedAt > 0) { + checksValue.textContent = new Date(entry.lastCheckedAt).toLocaleTimeString(); + } else { + checksValue.textContent = "—"; + } + checks.appendChild(checksLabel); + checks.appendChild(checksValue); + + details.appendChild(latency); + details.appendChild(errors); + details.appendChild(checks); + + item.appendChild(header); + item.appendChild(details); + + this.relayHealthList.appendChild(item); + }); + } + + handleRelayHealthTelemetryToggle() { + const service = this.services?.relayHealthService; + if (!service || !(this.relayHealthTelemetryToggle instanceof HTMLInputElement)) { + return; + } + + const enabled = service.setTelemetryOptIn( + this.relayHealthTelemetryToggle.checked, + ); + this.relayHealthTelemetryToggle.checked = enabled; + this.updateRelayHealthStatus( + enabled ? "Relay health telemetry enabled." : "Relay health telemetry disabled.", + ); + } + + async refreshRelayHealthPanel({ forceRefresh = false, reason = "" } = {}) { + const service = this.services?.relayHealthService; + if (!service || !this.relayHealthList) { + return []; + } + + if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) { + this.relayHealthTelemetryToggle.checked = service.getTelemetryOptIn(); + } + + const snapshot = service.getSnapshot(); + this.renderRelayHealthSnapshot(snapshot); + + if (!forceRefresh) { + return snapshot; + } + + if (this.relayHealthRefreshPromise) { + return this.relayHealthRefreshPromise; + } + + const statusMessage = reason === "manual" ? "Refreshing relay health…" : "Checking relays…"; + this.updateRelayHealthStatus(statusMessage); + + const refreshPromise = service + .refresh() + .then((latest) => { + this.renderRelayHealthSnapshot(latest); + this.updateRelayHealthStatus("Relay health updated."); + return latest; + }) + .catch((error) => { + this.updateRelayHealthStatus("Failed to refresh relay health."); + this.showError("Failed to refresh relay health."); + devLogger.warn("[profileModal] Relay health refresh failed:", error); + return []; + }) + .finally(() => { + if (this.relayHealthRefreshPromise === refreshPromise) { + this.relayHealthRefreshPromise = null; + } + }); + + this.relayHealthRefreshPromise = refreshPromise; + return refreshPromise; + } + async handleRelayOperation(meta = {}, { successMessage = "Relay preferences updated.", skipPublishIfUnchanged = true, @@ -6010,10 +6201,12 @@ export class ProfileModalController { this.showSuccess(unchangedMessage); } this.populateProfileRelays(); + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" }); return operationContext; } this.populateProfileRelays(); + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" }); if (operationContext.ok) { if (successMessage) {