From 19efb6a28e1f14c3756a7a8fce8f2b592b1253b7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 18 Jun 2026 10:07:26 +0300 Subject: [PATCH] feat(desktopApp): surface relay latency in dashboard + popup + banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of relay-latency-health: wire the Phase 1 tracker and Phase 2 store into the desktop UI across three surfaces. After this commit the feature is end-to-end usable in the running app. Wiring (Main.kt): - Construct a RelayLatencyTracker per account (same lifetime as RelayHealthStore). - Install a RelayLatencyListener alongside the existing RelayHealthListener on relayManager.client; uninstall both on account switch / app exit. - Pass the tracker to the store via the new latencyTracker constructor param so sweep + snapshot happen on the existing 60 s reclassify tick. - nip11Provider: read live from Nip11Fetcher's session cache (new `allCached()` accessor). The classifier reads it every tick. - authProvider: hardcoded `{ false }` for desktop — NIP-42 isn't wired in desktop yet, so any auth-required or payment-required relay is treated as "auth not complete" and excluded from the slow cohort. Avoids perpetually flagging paid relays that CLOSED our anonymous queries. RelayMetricsTab + RelayMetricCard (dashboard): - Tab collects latencySnapshots + slowRelays ONCE; per-row passes the per-relay value snapshots (not the whole map). Strong-skipping then handles the rest — unchanged rows skip on 60 s ticks. - Each row gains three compact columns: OK / EOSE / FR p50s (in ms). Missing metrics omit their cell — common in the first ~60 s before the tracker's first snapshot lands. - A red "Slow: 2.4×" AssistChip appears next to the columns when the classifier flags the relay. RelayDetailPanel (the per-row NIP-11 popup): - New "Latency (rolling last 50 samples)" section below the existing NIP-11 fields, listing each metric's p50, sample count, and cohort multiplier when the relay is currently flagged on that metric. - First-result row carries a tooltip explaining filter-dependence so users don't misread "slow first-result" as pure network slowness. UnhealthyRelaysPopup: - Now also collects store.slowRelays and renders a "Slow relays" section below the existing "Unresponsive relays" list (when slowRelays is non-empty). Each slow row: relay URL, metric + p50 vs cohort, slow chip, Dashboard + Snooze actions. Snooze reuses the existing 7-day snooze field on RelayHealthRecord. UnhealthyRelayBannerHost: - Banner now visible when either unhealthy OR slowRelays is non-empty. - Count text reads "$dead relays unresponsive — Review" / "$slow slow relays — Review" / "${dead+slow} relays need attention — Review" depending on which buckets have entries. Compose stability: - All public StateFlow types from RelayHealthStore expose ImmutableMap, and RelayLatencySnapshot is @Immutable with ImmutableMap fields, so strong-skipping engages. - Per-row composables (RelayMetricCard, SlowRelayPopupRow) only take @Immutable value parameters — no maps passed in. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 34 ++++++- .../amethyst/desktop/network/Nip11Fetcher.kt | 3 + .../desktop/ui/relay/RelayDetailPanel.kt | 84 ++++++++++++---- .../desktop/ui/relay/RelayMetricCard.kt | 67 ++++++++++++- .../desktop/ui/relay/RelayMetricsTab.kt | 17 ++++ .../relay/health/UnhealthyRelayBannerHost.kt | 17 ++-- .../ui/relay/health/UnhealthyRelaysPopup.kt | 98 +++++++++++++++++++ 7 files changed, 288 insertions(+), 32 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7b59dc24f7..3957057772 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -135,6 +135,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -1440,8 +1441,14 @@ fun MainContent( val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current // Relay-health store: per-account, persists liveness + snooze; installs a - // RelayConnectionListener so quartz lifecycle drives the timestamps. + // RelayConnectionListener so quartz lifecycle drives the timestamps. The latency tracker + // shares the same lifecycle — it's swept + snapshotted on the existing 60 s reclassify tick. val torStateForHealth = com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState.current + val relayLatencyTracker = + remember(account.pubKeyHex) { + com.vitorpamplona.amethyst.commons.relays.health + .RelayLatencyTracker() + } val relayHealthStore = remember(account.pubKeyHex) { com.vitorpamplona.amethyst.commons.relays.health.RelayHealthStore( @@ -1455,15 +1462,32 @@ fun MainContent( parentScope = scope, // `prefs.flush()` is blocking — keep it off the composition scope's Main dispatcher. ioDispatcher = kotlinx.coroutines.Dispatchers.IO, + latencyTracker = relayLatencyTracker, + nip11Provider = { + nip11Fetcher + .allCached() + .mapValues { entry -> + entry.value as com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation? + }.toPersistentMap() + }, + // Desktop doesn't handle NIP-42 AUTH yet — treat any auth-required relay as + // "auth not complete", so it's excluded from the slow cohort instead of being + // perpetually flagged for sending CLOSED to anonymous queries. + authProvider = { false }, ) } - DisposableEffect(relayHealthStore, relayManager) { - val listener = + DisposableEffect(relayHealthStore, relayManager, relayLatencyTracker) { + val healthListener = com.vitorpamplona.amethyst.commons.relays.health .RelayHealthListener(relayHealthStore) - listener.installInto(relayManager.client) + val latencyListener = + com.vitorpamplona.amethyst.commons.relays.health + .RelayLatencyListener(relayLatencyTracker) + healthListener.installInto(relayManager.client) + latencyListener.installInto(relayManager.client) onDispose { - listener.uninstallFrom(relayManager.client) + healthListener.uninstallFrom(relayManager.client) + latencyListener.uninstallFrom(relayManager.client) relayHealthStore.close() } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/Nip11Fetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/Nip11Fetcher.kt index dc00634582..038cd1cd79 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/Nip11Fetcher.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/Nip11Fetcher.kt @@ -95,6 +95,9 @@ class Nip11Fetcher { fun getCached(url: NormalizedRelayUrl): Nip11RelayInformation? = cache[url] + /** Snapshot of every NIP-11 doc fetched so far this session. */ + fun allCached(): Map = HashMap(cache) + fun clearCache() { cache.clear() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDetailPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDetailPanel.kt index 3546154ee3..433ef3a222 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDetailPanel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDetailPanel.kt @@ -31,11 +31,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.relays.health.LatencyMetric +import com.vitorpamplona.amethyst.commons.relays.health.RelayLatencySnapshot +import com.vitorpamplona.amethyst.commons.relays.health.SlowReason import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation @Composable fun RelayDetailPanel( nip11: Nip11RelayInformation?, + latency: RelayLatencySnapshot? = null, + slowReason: SlowReason? = null, modifier: Modifier = Modifier, ) { Column( @@ -43,7 +48,7 @@ fun RelayDetailPanel( ) { HorizontalDivider(modifier = Modifier.padding(bottom = 8.dp)) - if (nip11 == null) { + if (nip11 == null && latency == null) { Text( "Relay info unavailable", style = MaterialTheme.typography.bodySmall, @@ -52,28 +57,67 @@ fun RelayDetailPanel( return@Column } - // Description - nip11.description?.let { desc -> - Text(desc, style = MaterialTheme.typography.bodySmall) + nip11?.let { info -> + // Description + info.description?.let { desc -> + Text(desc, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + } + + // Software + version + info.software?.let { sw -> + val version = info.version?.let { " v$it" } ?: "" + DetailRow("Software", "$sw$version") + } + + // Supported NIPs + info.supported_nips?.let { nips -> + if (nips.isNotEmpty()) { + DetailRow("NIPs", nips.joinToString(", ")) + } + } + + // Payment status + val paymentRequired = info.limitation?.payment_required == true + DetailRow("Payment", if (paymentRequired) "Paid" else "Free") + } + + if (latency != null && latency.samples.isNotEmpty()) { Spacer(Modifier.height(8.dp)) - } - - // Software + version - nip11.software?.let { sw -> - val version = nip11.version?.let { " v$it" } ?: "" - DetailRow("Software", "$sw$version") - } - - // Supported NIPs - nip11.supported_nips?.let { nips -> - if (nips.isNotEmpty()) { - DetailRow("NIPs", nips.joinToString(", ")) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Text( + "Latency (rolling last 50 samples)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + LatencyMetric.entries.forEach { metric -> + val sample = latency.samples[metric] ?: return@forEach + val label = + when (metric) { + LatencyMetric.OK_ACK -> "OK ACK (publish→OK)" + LatencyMetric.EOSE -> "EOSE (REQ→EOSE)" + LatencyMetric.FIRST_RESULT -> "First result (REQ→1st event)" + LatencyMetric.PING -> "Ping (connect)" + } + val multiplier = + if (slowReason?.metric == metric) { + " — ${String.format("%.1f", slowReason.multiplier)}× cohort median" + } else { + "" + } + DetailRow(label, "${sample.p50Ms} ms (${sample.count} samples)$multiplier") + } + if (slowReason?.metric == LatencyMetric.FIRST_RESULT) { + Spacer(Modifier.height(2.dp)) + Text( + "First-result depends on filter content (popular pubkey ≠ quiet pubkey) — " + + "treat as a coarse signal alongside OK ACK / EOSE.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - - // Payment status - val paymentRequired = nip11.limitation?.payment_required == true - DetailRow("Payment", if (paymentRequired) "Paid" else "Free") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt index 692aa674ef..64c8e4f275 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt @@ -40,6 +40,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.health.LatencyMetric +import com.vitorpamplona.amethyst.commons.relays.health.RelayLatencySnapshot +import com.vitorpamplona.amethyst.commons.relays.health.SlowReason import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher import com.vitorpamplona.amethyst.desktop.network.RelayMetrics import com.vitorpamplona.amethyst.desktop.network.RelayStatus @@ -51,6 +54,8 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation fun RelayMetricCard( status: RelayStatus, metrics: RelayMetrics?, + latency: RelayLatencySnapshot?, + slowReason: SlowReason?, isExpanded: Boolean, onToggleExpand: () -> Unit, nip11Fetcher: Nip11Fetcher, @@ -123,6 +128,17 @@ fun RelayMetricCard( ) } + // Per-metric p50s (rolling last 50 samples). Three compact columns. Missing + // metrics simply omit their cell — common during the first ~60 s after start + // before the tracker's first snapshot lands. + LatencyCell("OK", latency?.p50Of(LatencyMetric.OK_ACK)) + LatencyCell("EOSE", latency?.p50Of(LatencyMetric.EOSE)) + LatencyCell("FR", latency?.p50Of(LatencyMetric.FIRST_RESULT)) + + if (slowReason != null) { + SlowRelayChip(slowReason) + } + // Event count if (metrics != null && metrics.eventCount > 0) { Text( @@ -156,11 +172,60 @@ fun RelayMetricCard( } AnimatedVisibility(isExpanded) { - RelayDetailPanel(nip11) + RelayDetailPanel(nip11, latency = latency, slowReason = slowReason) } } } +/** Compact single-column latency display: "label / p50ms" stacked. Hidden when no sample. */ +@Composable +private fun LatencyCell( + label: String, + p50: Int?, +) { + if (p50 == null) return + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "${p50}ms", + style = MaterialTheme.typography.labelSmall, + ) + } +} + +/** "Slow: OK 2.4×" outlined chip. Static (no ripple) — matches UnhealthyRelayRow's tag style. */ +@Composable +private fun SlowRelayChip(reason: SlowReason) { + val label = + when (reason.metric) { + LatencyMetric.OK_ACK -> "OK" + LatencyMetric.EOSE -> "EOSE" + LatencyMetric.FIRST_RESULT -> "FR" + LatencyMetric.PING -> "Ping" + } + val multiplierText = String.format("%.1f", reason.multiplier) + androidx.compose.material3.AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + "Slow: $label $multiplierText×", + style = MaterialTheme.typography.labelSmall, + ) + }, + colors = + androidx.compose.material3.AssistChipDefaults.assistChipColors( + disabledLabelColor = MaterialTheme.colorScheme.error, + disabledContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), + border = null, + ) +} + fun formatRelativeTime(epochMs: Long): String { val diffMs = System.currentTimeMillis() - epochMs val seconds = diffMs / 1000 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt index 49ee0f3363..bcf903b894 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayHealthStore import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -65,6 +66,20 @@ fun RelayMetricsTab( val metrics by relayManager.relayMetrics.collectAsState() + // Latency snapshots + slow classification — collected ONCE at tab scope. Rows take per-relay + // value snapshots (not the whole map) so strong-skipping skips unchanged rows on 60 s ticks. + val relayHealthStore = LocalRelayHealthStore.current + val latencySnapshots by + ( + relayHealthStore?.latencySnapshots + ?: kotlinx.coroutines.flow.MutableStateFlow(kotlinx.collections.immutable.persistentMapOf()) + ).collectAsState() + val slowRelays by + ( + relayHealthStore?.slowRelays + ?: kotlinx.coroutines.flow.MutableStateFlow(kotlinx.collections.immutable.persistentMapOf()) + ).collectAsState() + val connectedCount = statuses.count { it.connected } var expandedUrl by remember { mutableStateOf(null) } @@ -99,6 +114,8 @@ fun RelayMetricsTab( RelayMetricCard( status = status, metrics = metrics[status.url], + latency = latencySnapshots[status.url], + slowReason = slowRelays[status.url], isExpanded = expandedUrl == status.url, onToggleExpand = { expandedUrl = if (expandedUrl == status.url) null else status.url diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelayBannerHost.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelayBannerHost.kt index d2bd172b06..3b42bfa55d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelayBannerHost.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelayBannerHost.kt @@ -48,13 +48,18 @@ fun UnhealthyRelayBannerHost( val mutator = LocalRelayListMutator.current ?: return val unhealthy by store.unhealthy.collectAsState() + val slowRelays by store.slowRelays.collectAsState() val countText by remember { derivedStateOf { - val n = unhealthy.size - if (n == 1) { - "1 relay unresponsive — Review" - } else { - "$n relays unresponsive — Review" + val dead = unhealthy.size + val slow = slowRelays.size + when { + dead == 0 && slow == 0 -> "" + dead == 0 && slow == 1 -> "1 slow relay — Review" + dead == 0 -> "$slow slow relays — Review" + slow == 0 && dead == 1 -> "1 relay unresponsive — Review" + slow == 0 -> "$dead relays unresponsive — Review" + else -> "${dead + slow} relays need attention — Review" } } } @@ -63,7 +68,7 @@ fun UnhealthyRelayBannerHost( Box(modifier = modifier) { UnhealthyRelayBanner( - visible = unhealthy.isNotEmpty(), + visible = unhealthy.isNotEmpty() || slowRelays.isNotEmpty(), text = countText, onClick = { popupOpen = true }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt index 2e14ec43a7..137ce56052 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt @@ -29,6 +29,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -45,11 +47,15 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import com.vitorpamplona.amethyst.commons.relays.health.LatencyMetric import com.vitorpamplona.amethyst.commons.relays.health.RelayHealthStore import com.vitorpamplona.amethyst.commons.relays.health.RelayListKind import com.vitorpamplona.amethyst.commons.relays.health.RelayListMutator import com.vitorpamplona.amethyst.commons.relays.health.RelayRemovalResult +import com.vitorpamplona.amethyst.commons.relays.health.SlowReason import com.vitorpamplona.amethyst.commons.relays.health.ui.UnhealthyRelayRow +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.launch @@ -66,6 +72,7 @@ fun UnhealthyRelaysPopup( onShowMessage: (String) -> Unit = {}, ) { val unhealthy by store.unhealthy.collectAsState() + val slowRelays by store.slowRelays.collectAsState() val coScope = rememberCoroutineScope() Popup( @@ -147,11 +154,102 @@ fun UnhealthyRelaysPopup( ) HorizontalDivider() } + + // Slow relays section — relays that respond, but slower than 2× cohort median. + // Disjoint from the unresponsive list in practice (a slow relay is, by definition, + // still responding). + if (slowRelays.isNotEmpty()) { + Text( + text = "Slow relays", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Text( + text = + "These relays' median response is more than 2× the typical relay you're " + + "connected to. Open the dashboard for per-metric detail.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + slowRelays.forEach { (url, reason) -> + SlowRelayPopupRow( + url = url, + reason = reason, + onOpenDashboard = { + onDismiss() + onOpenDashboard() + }, + onSnooze = { + store.snooze(url) + onShowMessage("Snoozed for 7 days") + }, + ) + HorizontalDivider() + } + } } } } } +@Composable +private fun SlowRelayPopupRow( + url: NormalizedRelayUrl, + reason: SlowReason, + onOpenDashboard: () -> Unit, + onSnooze: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = url.displayUrl(), + style = MaterialTheme.typography.bodyMedium, + ) + val metricLabel = + when (reason.metric) { + LatencyMetric.OK_ACK -> "OK ACK" + LatencyMetric.EOSE -> "EOSE" + LatencyMetric.FIRST_RESULT -> "First result" + LatencyMetric.PING -> "Ping" + } + Text( + text = "$metricLabel ${reason.relayP50Ms} ms vs cohort ${reason.cohortP50Ms} ms", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + "Slow ${String.format("%.1f", reason.multiplier)}×", + style = MaterialTheme.typography.labelSmall, + ) + }, + colors = + AssistChipDefaults.assistChipColors( + disabledLabelColor = MaterialTheme.colorScheme.error, + disabledContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), + border = null, + ) + TextButton(onClick = onOpenDashboard) { + Text("Dashboard", style = MaterialTheme.typography.labelMedium) + } + TextButton(onClick = onSnooze) { + Text("Snooze 7d", style = MaterialTheme.typography.labelMedium) + } + } +} + private fun desktopListKindLabel(kind: RelayListKind): String = when (kind) { RelayListKind.Nip65 -> "Read/Write"