feat(ui): relay health indicator in NavigationRail

Add RelayHealthIndicator component — shows elapsed time since last
relay event. Hidden when <30s (healthy), shows "45s ago" / "3m ago"
when stale. Placed above BunkerHeartbeatIndicator in NavigationRail.
Reads from Coordinator's subscriptionHealth StateFlow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-23 09:00:34 +02:00
parent 50eb03bc3e
commit 8c1e54e147
3 changed files with 91 additions and 0 deletions
@@ -736,6 +736,7 @@ fun MainContent(
Row(Modifier.fillMaxSize().weight(1f)) {
when (layoutMode) {
LayoutMode.SINGLE_PANE -> {
val healthMap by subscriptionsCoordinator.subscriptionHealth.collectAsState()
SinglePaneLayout(
relayManager = relayManager,
localCache = localCache,
@@ -752,6 +753,7 @@ fun MainContent(
onZapFeedback = onZapFeedback,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
subscriptionHealth = healthMap,
modifier = Modifier.weight(1f),
)
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/**
* Displays relay health as elapsed time since last event.
* Hidden when < 30s (healthy). Shows "45s" or "3m" when stale.
*/
@Composable
fun RelayHealthIndicator(
lastEventReceivedAt: Long?,
modifier: Modifier = Modifier,
) {
if (lastEventReceivedAt == null) return
// Tick every 5s to update elapsed display
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
delay(5_000)
now = System.currentTimeMillis()
}
}
val elapsedMs = now - lastEventReceivedAt
if (elapsedMs < 30_000) return // healthy, don't show
val text = formatElapsed(elapsedMs)
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.padding(horizontal = 8.dp),
)
}
private fun formatElapsed(elapsedMs: Long): String {
val seconds = elapsedMs / 1000
return when {
seconds < 60 -> "${seconds}s ago"
seconds < 3600 -> "${seconds / 60}m ago"
else -> "${seconds / 3600}h ago"
}
}
@@ -65,7 +65,9 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionHealth
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
@@ -108,6 +110,7 @@ fun SinglePaneLayout(
onZapFeedback: (ZapFeedback) -> Unit,
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
subscriptionHealth: Map<String, SubscriptionHealth> = emptyMap(),
modifier: Modifier = Modifier,
) {
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
@@ -150,6 +153,16 @@ fun SinglePaneLayout(
Spacer(Modifier.weight(1f))
// Relay health — shows elapsed time since last event (hidden when <30s)
val latestEvent =
subscriptionHealth.values
.mapNotNull { it.lastEventReceivedAt }
.maxOrNull()
RelayHealthIndicator(
lastEventReceivedAt = latestEvent,
modifier = Modifier.padding(bottom = 4.dp),
)
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,