mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 4.7
parent
a07c9ac7cc
commit
19efb6a28e
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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()
|
||||
}
|
||||
|
||||
+64
-20
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+66
-1
@@ -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
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+11
-6
@@ -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 },
|
||||
)
|
||||
|
||||
+98
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user