feat(desktopApp): surface relay latency in dashboard + popup + banner

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: <metric> 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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-06-18 12:11:26 +03:00
co-authored by Claude Opus 4.7
parent a07c9ac7cc
commit 19efb6a28e
7 changed files with 288 additions and 32 deletions
@@ -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()
}
}
@@ -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<NormalizedRelayUrl, Nip11RelayInformation> = HashMap(cache)
fun clearCache() {
cache.clear()
}
@@ -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")
}
}
@@ -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
@@ -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<NormalizedRelayUrl?>(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
@@ -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 },
)
@@ -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"