From a557bff3d7eb4c8f698a76a3e679d2ac8f28c77d Mon Sep 17 00:00:00 2001 From: Andre K Date: Fri, 25 Oct 2024 08:14:32 -0300 Subject: [PATCH] feat: traceroute log (#1348) --- .../mesh/database/MeshLogRepository.kt | 11 +- .../geeksville/mesh/model/MetricsViewModel.kt | 51 +++++- .../java/com/geeksville/mesh/ui/NavGraph.kt | 4 + .../geeksville/mesh/ui/NodeDetailsScreen.kt | 18 +- .../mesh/ui/components/TracerouteLog.kt | 162 ++++++++++++++++++ app/src/main/res/values/strings.xml | 7 + 6 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index 742401113..c80a4db83 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -39,6 +39,14 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } .flowOn(Dispatchers.IO) + fun getLogsFrom( + nodeNum: Int, + portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, + maxItem: Int = MAX_MESH_PACKETS, + ): Flow> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem) + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + /* * Retrieves MeshPackets matching 'nodeNum' and 'portNum'. * If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'. @@ -47,8 +55,7 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L fun getMeshPacketsFrom( nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, - ): Flow> = meshLogDao.getLogsFrom(nodeNum, portNum, MAX_MESH_PACKETS) - .distinctUntilChanged() + ): Flow> = getLogsFrom(nodeNum, portNum) .mapLatest { list -> list.map { it.fromRadio.packet } } .flowOn(Dispatchers.IO) diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index 00c1130aa..a2efa9932 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -3,8 +3,10 @@ package com.geeksville.mesh.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.Portnums.PortNum import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.database.MeshLogRepository +import com.geeksville.mesh.database.entity.MeshLog import com.geeksville.mesh.repository.datastore.RadioConfigRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -16,9 +18,11 @@ import kotlinx.coroutines.flow.stateIn import javax.inject.Inject data class MetricsState( + val isManaged: Boolean = true, val deviceMetrics: List = emptyList(), val environmentMetrics: List = emptyList(), val signalMetrics: List = emptyList(), + val hasTracerouteLogs: Boolean = false, val environmentDisplayFahrenheit: Boolean = false, ) { fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() @@ -30,35 +34,72 @@ data class MetricsState( } } +data class TracerouteLogState( + val requests: List = emptyList(), + val results: List = emptyList(), +) { + companion object { + val Empty = TracerouteLogState() + } +} + @HiltViewModel class MetricsViewModel @Inject constructor( meshLogRepository: MeshLogRepository, - radioConfigRepository: RadioConfigRepository, + private val radioConfigRepository: RadioConfigRepository, ) : ViewModel() { private val destNum = MutableStateFlow(0) private fun MeshPacket.hasValidSignal(): Boolean = rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0) + private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) { + hasDecoded() && decoded.wantResponse && from == 0 && to == destNum.value + } + + fun getUser(nodeNum: Int) = radioConfigRepository.getUser(nodeNum) + + @OptIn(ExperimentalCoroutinesApi::class) + val tracerouteState = destNum.flatMapLatest { destNum -> + combine( + meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE), + meshLogRepository.getMeshPacketsFrom(destNum), + ) { request, response -> + val test = request.filter { it.hasValidTraceroute() } + TracerouteLogState( + requests = test, + results = response, + ) + } + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = TracerouteLogState.Empty, + ) + @OptIn(ExperimentalCoroutinesApi::class) val state = destNum.flatMapLatest { destNum -> combine( meshLogRepository.getTelemetryFrom(destNum), meshLogRepository.getMeshPacketsFrom(destNum), - radioConfigRepository.moduleConfigFlow, - ) { telemetry, meshPackets, config -> + meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE), + radioConfigRepository.deviceProfileFlow, + ) { telemetry, meshPackets, traceroute, profile -> + val moduleConfig = profile.moduleConfig MetricsState( + isManaged = profile.config.security.isManaged, deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, environmentMetrics = telemetry.filter { it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f }, signalMetrics = meshPackets.filter { it.hasValidSignal() }, - environmentDisplayFahrenheit = config.telemetry.environmentDisplayFahrenheit, + hasTracerouteLogs = traceroute.any { it.hasValidTraceroute() }, + environmentDisplayFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit, ) } }.stateIn( scope = viewModelScope, - started = WhileSubscribed(), + started = WhileSubscribed(stopTimeoutMillis = 5000L), initialValue = MetricsState.Empty, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt index 8027d481d..d209edd90 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -66,6 +66,7 @@ import com.geeksville.mesh.service.MeshService.ConnectionState import com.geeksville.mesh.ui.components.DeviceMetricsScreen import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen import com.geeksville.mesh.ui.components.SignalMetricsScreen +import com.geeksville.mesh.ui.components.TracerouteLogScreen import com.geeksville.mesh.ui.components.config.AmbientLightingConfigItemList import com.geeksville.mesh.ui.components.config.AudioConfigItemList import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList @@ -297,6 +298,9 @@ fun NavGraph( metricsState.environmentDisplayFahrenheit, ) } + composable("TracerouteList") { + TracerouteLogScreen(metricsViewModel) + } composable("SignalMetrics") { SignalMetricsScreen(metricsState.signalMetrics) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt index e390d2663..d980c6dd1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt @@ -36,6 +36,8 @@ import androidx.compose.material.icons.filled.KeyOff import androidx.compose.material.icons.filled.Numbers import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Power +import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.filled.Router import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SignalCellularAlt import androidx.compose.material.icons.filled.Speed @@ -93,6 +95,7 @@ fun NodeDetailsScreen( } } +@Suppress("LongMethod") @Composable private fun NodeDetailsItemList( node: NodeEntity, @@ -151,10 +154,18 @@ private fun NodeDetailsItemList( onNavigate("SignalMetrics") } + NavCard( + title = stringResource(R.string.traceroute_logs), + icon = Icons.Default.Route, + enabled = metricsState.hasTracerouteLogs + ) { + onNavigate("TracerouteList") + } + NavCard( title = "Remote Administration", icon = Icons.Default.Settings, - enabled = !node.user.isLicensed // TODO check for isManaged + enabled = !metricsState.isManaged || !node.user.isLicensed ) { onNavigate("RadioConfig") } @@ -219,6 +230,11 @@ private fun NodeDetailsContent(node: NodeEntity) { icon = Icons.Default.Work, value = node.user.role.name ) + NodeDetailRow( + label = "Hardware", + icon = Icons.Default.Router, + value = node.user.hwModel.name + ) if (node.deviceMetrics.uptimeSeconds > 0) { NodeDetailRow( label = "Uptime", diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt new file mode 100644 index 000000000..555ac0fa5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt @@ -0,0 +1,162 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PersonOff +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.getTracerouteResponse +import com.geeksville.mesh.ui.theme.AppTheme +import java.text.DateFormat + +@Composable +fun TracerouteLogScreen( + viewModel: MetricsViewModel = hiltViewModel(), + modifier: Modifier = Modifier, +) { + val state by viewModel.tracerouteState.collectAsStateWithLifecycle() + val dateFormat = remember { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + } + + fun getUsername(nodeNum: Int): String = + with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } + + var showDialog by remember { mutableStateOf(null) } + + if (showDialog != null) { + val message = showDialog ?: return + SimpleAlertDialog( + title = R.string.traceroute, + text = { + SelectionContainer { + Text(text = message) + } + }, + onDismiss = { showDialog = null } + ) + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(state.requests, key = { it.uuid }) { log -> + val result = remember(state.requests) { + state.results.find { it.decoded.requestId == log.fromRadio.packet.id } + } + val route = remember(result) { + result?.let { MeshProtos.RouteDiscovery.parseFrom(it.decoded.payload) } + } + + val time = dateFormat.format(log.received_date) + val (text, icon) = route.getTextAndIcon() + + TracerouteItem( + icon = icon, + text = "$time - $text", + modifier = Modifier.clickable(enabled = result != null) { + if (result != null) { + showDialog = result.getTracerouteResponse(::getUsername) + } + } + ) + } + } +} + +@Composable +private fun TracerouteItem( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(vertical = 2.dp), + elevation = 4.dp + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = stringResource(id = R.string.traceroute) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.body1, + ) + } + } +} + +@Composable +private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair = when { + this == null -> { + stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff + } + + routeList.isEmpty() -> { + stringResource(R.string.traceroute_direct) to Icons.Default.Group + } + + routeList.size == routeBackList.size -> { + val hops = routeList.size + pluralStringResource(R.plurals.traceroute_hops, hops, hops) to Icons.Default.Groups + } + + else -> { + val (towards, back) = routeList.size to routeBackList.size + stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups + } +} + +@PreviewLightDark +@Composable +private fun TracerouteItemPreview() { + val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + AppTheme { + TracerouteItem( + icon = Icons.Default.Group, + text = "${dateFormat.format(System.currentTimeMillis())} - Direct" + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45bada70a..d456d2de3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,4 +287,11 @@ None Signal Signal Quality + Traceroute Logs + Direct + + 1 hop + %d hops + + Hops towards %d Hops back %d