feat: traceroute log (#1348)

pull/1350/head
Andre K 2024-10-25 08:14:32 -03:00 zatwierdzone przez GitHub
rodzic a3b4b70db9
commit a557bff3d7
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
6 zmienionych plików z 245 dodań i 8 usunięć

Wyświetl plik

@ -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<List<MeshLog>> = 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<List<MeshPacket>> = meshLogDao.getLogsFrom(nodeNum, portNum, MAX_MESH_PACKETS)
.distinctUntilChanged()
): Flow<List<MeshPacket>> = getLogsFrom(nodeNum, portNum)
.mapLatest { list -> list.map { it.fromRadio.packet } }
.flowOn(Dispatchers.IO)

Wyświetl plik

@ -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<Telemetry> = emptyList(),
val environmentMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = 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<MeshLog> = emptyList(),
val results: List<MeshPacket> = 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,
)

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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",

Wyświetl plik

@ -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<String?>(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<String, ImageVector> = 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"
)
}
}

Wyświetl plik

@ -287,4 +287,11 @@
<string name="none_quality">None</string>
<string name="signal">Signal</string>
<string name="signal_quality">Signal Quality</string>
<string name="traceroute_logs">Traceroute Logs</string>
<string name="traceroute_direct">Direct</string>
<plurals name="traceroute_hops">
<item quantity="one">1 hop</item>
<item quantity="other">%d hops</item>
</plurals>
<string name="traceroute_diff">Hops towards %d Hops back %d</string>
</resources>