From 8554b82bd5d5f8b3d6438c4eb3e7167f894a4ced Mon Sep 17 00:00:00 2001 From: ChinuWisibility Date: Tue, 28 Jul 2026 21:54:05 +0530 Subject: [PATCH] status ui upgrade --- .../podroid/ui/components/LiveLineChart.kt | 67 +++ .../podroid/ui/components/MetricChartCard.kt | 130 +++++ .../podroid/ui/screens/status/StatusScreen.kt | 488 ++++++++++++------ .../ui/screens/status/StatusViewModel.kt | 200 +++++-- .../com/excp/podroid/util/MetricHistory.kt | 12 + .../excp/podroid/util/NetworkRateSampler.kt | 59 +++ .../com/excp/podroid/util/PhoneCpuSampler.kt | 60 +++ app/src/main/res/values-zh/strings.xml | 29 +- app/src/main/res/values/strings.xml | 29 +- 9 files changed, 870 insertions(+), 204 deletions(-) create mode 100644 app/src/main/java/com/excp/podroid/ui/components/LiveLineChart.kt create mode 100644 app/src/main/java/com/excp/podroid/ui/components/MetricChartCard.kt create mode 100644 app/src/main/java/com/excp/podroid/util/MetricHistory.kt create mode 100644 app/src/main/java/com/excp/podroid/util/NetworkRateSampler.kt create mode 100644 app/src/main/java/com/excp/podroid/util/PhoneCpuSampler.kt diff --git a/app/src/main/java/com/excp/podroid/ui/components/LiveLineChart.kt b/app/src/main/java/com/excp/podroid/ui/components/LiveLineChart.kt new file mode 100644 index 00000000..bf6b9d07 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/components/LiveLineChart.kt @@ -0,0 +1,67 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + */ +package com.excp.podroid.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp + +data class ChartSeries( + val values: List, + val color: Color, +) + +@Composable +fun LiveLineChart( + series: List, + modifier: Modifier = Modifier, + yMax: Float? = null, + heightDp: Int = 120, +) { + val gridColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f) + Canvas( + modifier = modifier + .fillMaxWidth() + .height(heightDp.dp), + ) { + val w = size.width + val h = size.height + if (w <= 0f || h <= 0f) return@Canvas + + // Horizontal grid (Azure-like) + for (i in 0..3) { + val y = h * i / 3f + drawLine(gridColor, Offset(0f, y), Offset(w, y), strokeWidth = 1f) + } + + val peak = series.flatMap { it.values }.maxOrNull()?.coerceAtLeast(0.01f) ?: 1f + val maxY = (yMax ?: (peak * 1.15f)).coerceAtLeast(0.01f) + + series.forEach { s -> + if (s.values.size < 2) return@forEach + val path = Path() + val n = s.values.size + s.values.forEachIndexed { i, v -> + val x = if (n == 1) 0f else w * i / (n - 1) + val y = h - (v.coerceIn(0f, maxY) / maxY) * h + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath( + path = path, + color = s.color, + style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round), + ) + } + } +} diff --git a/app/src/main/java/com/excp/podroid/ui/components/MetricChartCard.kt b/app/src/main/java/com/excp/podroid/ui/components/MetricChartCard.kt new file mode 100644 index 00000000..5a2d9b96 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/components/MetricChartCard.kt @@ -0,0 +1,130 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Azure-style metric tile: title, live line chart, colored legend rows. + */ +package com.excp.podroid.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.excp.podroid.ui.theme.PodroidTokens + +data class MetricLegend( + val label: String, + val value: String, + val color: Color, + val subtitle: String? = null, +) + +@Composable +fun MetricChartCard( + title: String, + legends: List, + series: List, + modifier: Modifier = Modifier, + yMax: Float? = null, + unavailable: String? = null, + timeHint: String? = null, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(PodroidTokens.Radius.Chip), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.padding(PodroidTokens.Spacing.MD), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (unavailable != null) { + Text( + text = unavailable, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(top = PodroidTokens.Spacing.LG), + ) + } else { + LiveLineChart(series = series, yMax = yMax) + if (timeHint != null) { + Text( + text = timeHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + legends.forEach { legend -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer( + modifier = Modifier + .width(3.dp) + .height(28.dp) + .background(legend.color, RoundedCornerShape(1.dp)), + ) + Spacer(modifier = Modifier.width(PodroidTokens.Spacing.SM)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = legend.label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + if (legend.subtitle != null) { + Text( + text = legend.subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + text = legend.value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/excp/podroid/ui/screens/status/StatusScreen.kt b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusScreen.kt index febf79af..075187ad 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/status/StatusScreen.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusScreen.kt @@ -1,45 +1,72 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Azure-style live metric dashboard for phone + VM resources. + */ package com.excp.podroid.ui.screens.status +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.excp.podroid.R import com.excp.podroid.engine.EngineSelection import com.excp.podroid.engine.VmState import com.excp.podroid.ui.components.AdaptiveContainer +import com.excp.podroid.ui.components.ChartSeries +import com.excp.podroid.ui.components.MetricChartCard +import com.excp.podroid.ui.components.MetricLegend import com.excp.podroid.ui.components.PodroidListRow import com.excp.podroid.ui.components.PodroidSectionLabel import com.excp.podroid.ui.components.PodroidTopBar -import com.excp.podroid.ui.components.VmLoadGraph import com.excp.podroid.ui.theme.PodroidTokens import com.excp.podroid.util.HostMetrics +private const val TAB_PHONE = 0 +private const val TAB_VM = 1 + +private val AzureBlue = Color(0xFF0078D4) +private val AzurePink = Color(0xFFE91E8C) +private val AzureTeal = Color(0xFF00BCF2) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun StatusScreen( @@ -48,10 +75,23 @@ fun StatusScreen( viewModel: StatusViewModel = hiltViewModel(), ) { val ui by viewModel.uiState.collectAsStateWithLifecycle() - val metrics = ui.metrics - val phoneRamUsedMb = (metrics.phoneTotalRamMb - metrics.phoneAvailRamMb).coerceAtLeast(0) - val phoneStorageUsedGb = (metrics.phoneStorageTotalGb - metrics.phoneStorageAvailGb) - .coerceAtLeast(0.0) + var selectedTab by rememberSaveable { mutableIntStateOf(TAB_VM) } + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> viewModel.setLiveActive(true) + Lifecycle.Event.ON_PAUSE -> viewModel.setLiveActive(false) + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + viewModel.setLiveActive(false) + lifecycleOwner.lifecycle.removeObserver(observer) + } + } Scaffold( topBar = { @@ -71,181 +111,301 @@ fun StatusScreen( .fillMaxSize() .padding(innerPadding), ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = PodroidTokens.Spacing.XL, vertical = PodroidTokens.Spacing.LG), - verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.MD), - ) { - PodroidSectionLabel(stringResource(R.string.status_phone_section)) - StatusMetricBar( - label = stringResource(R.string.ram_label), - detail = "${HostMetrics.formatMb(phoneRamUsedMb)} / ${HostMetrics.formatMb(metrics.phoneTotalRamMb)}", - progress = HostMetrics.percent(phoneRamUsedMb, metrics.phoneTotalRamMb), - ) - StatusMetricBar( - label = stringResource(R.string.storage), - detail = "${HostMetrics.formatGb(phoneStorageUsedGb)} / ${HostMetrics.formatGb(metrics.phoneStorageTotalGb)}", - progress = HostMetrics.percent(phoneStorageUsedGb, metrics.phoneStorageTotalGb), - ) - PodroidListRow( - label = stringResource(R.string.cpu_cores), - value = "${metrics.phoneCpuCores}", - ) - // /proc/loadavg is SELinux-blocked for third-party apps on modern - // Android, so this is usually absent; hide the row rather than - // show a permanent placeholder. - if (metrics.loadAvg1 != null) { - PodroidListRow( - label = stringResource(R.string.status_load_average), - value = String.format( - "%.2f · %.2f · %.2f", - metrics.loadAvg1, - metrics.loadAvg5, - metrics.loadAvg15, - ), - mono = true, + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PodroidTokens.Spacing.XL, vertical = PodroidTokens.Spacing.SM), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusLiveBadge(active = ui.liveTick > 0L) + } + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == TAB_PHONE, + onClick = { selectedTab = TAB_PHONE }, + text = { Text(stringResource(R.string.status_tab_phone)) }, + ) + Tab( + selected = selectedTab == TAB_VM, + onClick = { selectedTab = TAB_VM }, + text = { Text(stringResource(R.string.status_tab_vm)) }, ) } + when (selectedTab) { + TAB_PHONE -> PhoneStatusTab(ui = ui) + TAB_VM -> VmStatusTab(ui = ui) + } + } + } + } +} - Spacer(Modifier.height(PodroidTokens.Spacing.SM)) - PodroidSectionLabel(stringResource(R.string.status_vm_section)) - PodroidListRow( - label = stringResource(R.string.vm_status), - value = vmStatusLabel(ui.vmState, ui.uptimeLabel), - ) - PodroidListRow( - label = stringResource(R.string.backend), - value = backendLabel(ui.engineSelection, ui.backendId), - mono = true, - ) - PodroidListRow( - label = stringResource(R.string.phone_ip), - value = ui.phoneIp, - mono = true, - ) +@Composable +private fun StatusLiveBadge(active: Boolean) { + if (!active) return + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + Text( + text = stringResource(R.string.status_live_badge), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.status_live_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} - Text( - text = stringResource(R.string.status_vm_load_graph_title), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = PodroidTokens.Spacing.SM), - ) - VmLoadGraph( - samples = ui.vmLoadHistory, - currentPercent = ui.vmLoadPercent, - unavailableReason = when { - ui.vmState !is VmState.Running -> stringResource(R.string.status_vm_load_stopped) - ui.vmLoadGraphUnavailable == "avf" -> stringResource(R.string.status_vm_load_avf_only) - else -> null - }, - modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM), - ) +@Composable +private fun PhoneStatusTab(ui: StatusUiState) { + val metrics = ui.metrics + val charts = ui.charts + val phoneRamUsedMb = (metrics.phoneTotalRamMb - metrics.phoneAvailRamMb).coerceAtLeast(0) + val phoneStorageUsedGb = (metrics.phoneStorageTotalGb - metrics.phoneStorageAvailGb).coerceAtLeast(0.0) + val resource = stringResource(R.string.status_resource_phone) - StatusMetricBar( - label = stringResource(R.string.status_vm_ram_allocated), - detail = HostMetrics.formatMb(ui.vmRamMb.toLong()), - progress = if (metrics.emulatorRssMb != null && ui.vmRamMb > 0) { - HostMetrics.percent(metrics.emulatorRssMb, ui.vmRamMb.toLong()) - } else null, - sublabel = metrics.emulatorRssMb?.let { - stringResource(R.string.status_emulator_rss, HostMetrics.formatMb(it)) - }, - ) - PodroidListRow( - label = stringResource(R.string.cpu_cores), - value = "${ui.vmCpus}", - ) - StatusMetricBar( - label = stringResource(R.string.status_vm_disk), - detail = "${HostMetrics.formatGb(metrics.vmDiskImageBytes)} / ${ui.storageSizeGb} GB", - progress = HostMetrics.percent( - metrics.vmDiskImageBytes, - ui.storageSizeGb.toLong() * 1024L * 1024L * 1024L, - ), - ) - PodroidListRow( - label = stringResource(R.string.bandwidth_limit), - value = if (ui.bandwidthMbps <= 0) { - stringResource(R.string.bandwidth_unlimited) - } else { - "${ui.bandwidthMbps} Mbps" - }, - ) - PodroidListRow( - label = stringResource(R.string.load_balance), - value = if (ui.loadBalanceEnabled) { - stringResource(R.string.on) - } else { - stringResource(R.string.off) - }, - ) - PodroidListRow( - label = stringResource(R.string.port_forwards), - value = "${ui.portForwardCount}", - divider = false, - ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = PodroidTokens.Spacing.LG, vertical = PodroidTokens.Spacing.MD), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.MD), + ) { + MetricChartCard( + title = stringResource(R.string.status_metric_phone_cpu), + series = listOf(ChartSeries(charts.phoneCpu, AzureBlue)), + yMax = 100f, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.status_metric_cpu_legend), + subtitle = resource, + value = ui.phoneCpuPercent?.let { String.format("%.1f%%", it) } + ?: stringResource(R.string.status_vm_load_collecting), + color = AzureBlue, + ), + ), + ) + MetricChartCard( + title = stringResource(R.string.status_metric_phone_ram), + series = listOf(ChartSeries(charts.phoneRam, AzurePink)), + yMax = 100f, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.ram_label), + subtitle = resource, + value = "${HostMetrics.formatMb(phoneRamUsedMb)} / ${HostMetrics.formatMb(metrics.phoneTotalRamMb)}", + color = AzurePink, + ), + ), + ) + MetricChartCard( + title = stringResource(R.string.status_metric_phone_storage), + series = listOf(ChartSeries(charts.phoneStorage, AzureTeal)), + yMax = 100f, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.storage), + subtitle = resource, + value = "${HostMetrics.formatGb(phoneStorageUsedGb)} / ${HostMetrics.formatGb(metrics.phoneStorageTotalGb)}", + color = AzureTeal, + ), + ), + ) + MetricChartCard( + title = stringResource(R.string.status_metric_network), + series = listOf( + ChartSeries(charts.netIn, AzureBlue), + ChartSeries(charts.netOut, AzurePink), + ), + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.status_metric_network_in), + subtitle = resource, + value = formatRate(ui.netInBps), + color = AzureBlue, + ), + MetricLegend( + label = stringResource(R.string.status_metric_network_out), + subtitle = resource, + value = formatRate(ui.netOutBps), + color = AzurePink, + ), + ), + ) - Text( - text = stringResource(R.string.status_refresh_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = PodroidTokens.Spacing.SM), - ) - } + PodroidSectionLabel(stringResource(R.string.status_phone_details)) + PodroidListRow(label = stringResource(R.string.cpu_cores), value = "${metrics.phoneCpuCores}") + if (metrics.loadAvg1 != null) { + PodroidListRow( + label = stringResource(R.string.status_load_average), + value = String.format("%.2f · %.2f · %.2f", metrics.loadAvg1, metrics.loadAvg5, metrics.loadAvg15), + mono = true, + ) } + PodroidListRow(label = stringResource(R.string.phone_ip), value = ui.phoneIp, mono = true, divider = false) } } @Composable -private fun StatusMetricBar( - label: String, - detail: String, - progress: Float?, - sublabel: String? = null, -) { +private fun VmStatusTab(ui: StatusUiState) { + val metrics = ui.metrics + val charts = ui.charts + val resource = stringResource(R.string.status_resource_vm) + val vmCpuUnavailable = when { + ui.vmState !is VmState.Running -> stringResource(R.string.status_vm_load_stopped) + ui.vmLoadGraphUnavailable == "avf" -> stringResource(R.string.status_vm_load_avf_only) + else -> null + } + val stopped = if (ui.vmState !is VmState.Running) stringResource(R.string.status_vm_load_stopped) else null + val diskUsedPct = if (ui.storageSizeGb > 0) { + metrics.vmDiskImageBytes.toDouble() / (ui.storageSizeGb.toLong() * 1024 * 1024 * 1024) * 100.0 + } else 0.0 + val lastDiskAct = charts.vmDiskActivity.lastOrNull() ?: 0f + val availValue = if (ui.vmState is VmState.Running) "1" else "0" + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = PodroidTokens.Spacing.SM), + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = PodroidTokens.Spacing.LG, vertical = PodroidTokens.Spacing.MD), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.MD), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.bodyMedium) - Text( - detail, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (progress != null) { - Spacer(Modifier.height(PodroidTokens.Spacing.XS)) - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier.fillMaxWidth(), - ) - } - if (sublabel != null) { - Spacer(Modifier.height(PodroidTokens.Spacing.XS)) - Text( - sublabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + MetricChartCard( + title = stringResource(R.string.status_metric_vm_cpu), + series = listOf(ChartSeries(charts.vmCpu, AzureBlue)), + yMax = 100f, + unavailable = vmCpuUnavailable, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.status_metric_cpu_legend), + subtitle = resource, + value = ui.vmLoadPercent?.let { String.format("%.2f%%", it) } + ?: stringResource(R.string.status_vm_load_collecting), + color = AzureBlue, + ), + ), + ) + MetricChartCard( + title = stringResource(R.string.status_metric_vm_memory), + series = listOf(ChartSeries(charts.vmRam, AzurePink)), + unavailable = stopped, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.status_metric_memory_legend), + subtitle = resource, + value = metrics.emulatorRssMb?.let { HostMetrics.formatMb(it) } + ?: stringResource(R.string.status_unavailable), + color = AzurePink, + ), + ), + ) + MetricChartCard( + title = stringResource(R.string.status_metric_vm_disk), + series = listOf(ChartSeries(charts.vmDisk, AzureBlue)), + unavailable = stopped, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.status_vm_disk), + subtitle = resource, + value = "${HostMetrics.formatGb(metrics.vmDiskImageBytes)} (${String.format("%.1f%%", diskUsedPct)})", + color = AzureBlue, + ), + ), + ) + MetricChartCard( + title = stringResource(R.string.status_metric_vm_disk_activity), + series = listOf(ChartSeries(charts.vmDiskActivity, AzurePink)), + unavailable = stopped, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.status_metric_disk_write_legend), + subtitle = resource, + value = String.format("%.1f KB/sample", lastDiskAct), + color = AzurePink, + ), + ), + ) + MetricChartCard( + title = stringResource(R.string.status_metric_vm_availability), + series = listOf(ChartSeries(charts.vmAvailability, AzureBlue)), + yMax = 1f, + timeHint = stringResource(R.string.status_chart_window), + legends = listOf( + MetricLegend( + label = stringResource(R.string.status_metric_availability_legend), + subtitle = resource, + value = availValue, + color = AzureBlue, + ), + ), + ) + + PodroidSectionLabel(stringResource(R.string.status_vm_details)) + PodroidListRow( + label = stringResource(R.string.vm_status), + value = vmStatusLabel(ui.vmState, ui.uptimeLabel, ui.bootStage), + ) + PodroidListRow( + label = stringResource(R.string.backend), + value = backendLabel(ui.engineSelection, ui.backendId), + mono = true, + ) + PodroidListRow(label = stringResource(R.string.cpu_cores), value = "${ui.vmCpus}") + PodroidListRow( + label = stringResource(R.string.status_vm_ram_allocated), + value = HostMetrics.formatMb(ui.vmRamMb.toLong()), + ) + PodroidListRow( + label = stringResource(R.string.bandwidth_limit), + value = if (ui.bandwidthMbps <= 0) { + stringResource(R.string.bandwidth_unlimited) + } else { + "${ui.bandwidthMbps} Mbps" + }, + ) + PodroidListRow( + label = stringResource(R.string.port_forwards), + value = "${ui.portForwardCount}", + divider = false, + ) + } +} + +@Composable +private fun formatRate(bytesPerSec: Float?): String { + if (bytesPerSec == null) return stringResource(R.string.status_vm_load_collecting) + return when { + bytesPerSec >= 1024f * 1024f -> String.format("%.2f MiB/s", bytesPerSec / (1024f * 1024f)) + bytesPerSec >= 1024f -> String.format("%.1f KiB/s", bytesPerSec / 1024f) + else -> String.format("%.0f B/s", bytesPerSec) } } @Composable -private fun vmStatusLabel(vmState: VmState, uptime: String?): String = when (vmState) { +private fun vmStatusLabel(vmState: VmState, uptime: String?, bootStage: String): String = when (vmState) { is VmState.Running -> uptime?.let { "${stringResource(R.string.status_running)} · $it" } ?: stringResource(R.string.status_running) - is VmState.Starting -> stringResource(R.string.status_starting) + is VmState.Starting -> bootStage.ifEmpty { stringResource(R.string.status_starting) } is VmState.Error -> stringResource(R.string.status_error) else -> stringResource(R.string.status_stopped) } diff --git a/app/src/main/java/com/excp/podroid/ui/screens/status/StatusViewModel.kt b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusViewModel.kt index 42234c08..42cbe4d0 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/status/StatusViewModel.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusViewModel.kt @@ -1,3 +1,7 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + */ package com.excp.podroid.ui.screens.status import android.content.Context @@ -10,7 +14,10 @@ import com.excp.podroid.engine.VmEngine import com.excp.podroid.engine.VmState import com.excp.podroid.util.HostMetrics import com.excp.podroid.util.HostMetricsSnapshot +import com.excp.podroid.util.MetricHistory +import com.excp.podroid.util.NetworkRateSampler import com.excp.podroid.util.NetworkUtils +import com.excp.podroid.util.PhoneCpuSampler import com.excp.podroid.util.VmLoadSampler import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -25,6 +32,19 @@ import kotlinx.coroutines.launch import java.io.File import javax.inject.Inject +data class StatusChartHistories( + val phoneCpu: List = emptyList(), + val phoneRam: List = emptyList(), + val phoneStorage: List = emptyList(), + val netIn: List = emptyList(), + val netOut: List = emptyList(), + val vmCpu: List = emptyList(), + val vmRam: List = emptyList(), + val vmDisk: List = emptyList(), + val vmDiskActivity: List = emptyList(), + val vmAvailability: List = emptyList(), +) + data class StatusUiState( val vmState: VmState = VmState.Idle, val backendId: String = "qemu", @@ -37,9 +57,15 @@ data class StatusUiState( val bandwidthMbps: Int = 0, val loadBalanceEnabled: Boolean = false, val portForwardCount: Int = 0, + val bootStage: String = "", val vmLoadPercent: Float? = null, - val vmLoadHistory: List = emptyList(), + val phoneCpuPercent: Float? = null, + val netInBps: Float? = null, + val netOutBps: Float? = null, + val liveTick: Long = 0L, + val lastRefreshMs: Long = 0L, val vmLoadGraphUnavailable: String? = null, + val charts: StatusChartHistories = StatusChartHistories(), val metrics: HostMetricsSnapshot = HostMetricsSnapshot( phoneTotalRamMb = 0, phoneAvailRamMb = 0, @@ -65,83 +91,185 @@ class StatusViewModel @Inject constructor( private val _metrics = MutableStateFlow(defaultMetrics()) private val _uptimeTick = MutableStateFlow(0L) private val _vmLoadPercent = MutableStateFlow(null) - private val _vmLoadHistory = MutableStateFlow>(emptyList()) private val _vmLoadUnavailable = MutableStateFlow(null) + private val _chartHistories = MutableStateFlow(StatusChartHistories()) + private val _lastRefreshMs = MutableStateFlow(0L) + private val _liveTick = MutableStateFlow(0L) + private val _phoneIp = MutableStateFlow("—") + private val _phoneCpuPercent = MutableStateFlow(null) + private val _netInBps = MutableStateFlow(null) + private val _netOutBps = MutableStateFlow(null) + private val _liveActive = MutableStateFlow(false) + private var lastDiskBytes = 0L private val loadSampler = VmLoadSampler() + private val phoneCpuSampler = PhoneCpuSampler() + private val networkSampler = NetworkRateSampler() + + companion object { + private const val POLL_MS = 500L + } val uiState: StateFlow = combine( combine( engine.state, + engine.bootStage, settingsRepository.vmRamMb, settingsRepository.vmCpus, settingsRepository.storageSizeGb, - settingsRepository.bandwidthMbps, - ) { vmState, ram, cpus, storage, bandwidth -> - arrayOf(vmState, ram, cpus, storage, bandwidth) + ) { vmState, bootStage, ram, cpus, storage -> + arrayOf(vmState, bootStage, ram, cpus, storage) }, combine( + settingsRepository.bandwidthMbps, settingsRepository.loadBalanceEnabled, settingsRepository.engineSelection, portForwardRepository.rules, _metrics, - _uptimeTick, - ) { loadBal, engineSel, rules, metrics, tick -> - arrayOf(loadBal, engineSel, rules, metrics, tick) + ) { bandwidth, loadBal, engineSel, rules, metrics -> + arrayOf(bandwidth, loadBal, engineSel, rules, metrics) }, combine( + _uptimeTick, _vmLoadPercent, - _vmLoadHistory, _vmLoadUnavailable, - ) { pct, history, unavailable -> - arrayOf(pct, history, unavailable) + _chartHistories, + _phoneCpuPercent, + ) { tick, pct, unavailable, charts, phoneCpu -> + arrayOf(tick, pct, unavailable, charts, phoneCpu) }, - ) { a, b, c -> + combine( + _lastRefreshMs, + _liveTick, + _phoneIp, + _netInBps, + _netOutBps, + ) { refreshed, live, ip, netIn, netOut -> + arrayOf(refreshed, live, ip, netIn, netOut) + }, + ) { a, b, c, d -> val vmState = a[0] as VmState - val tick = b[4] as Long + val tick = c[0] as Long + val charts = c[3] as StatusChartHistories StatusUiState( vmState = vmState, + bootStage = a[1] as String, backendId = engine.backendId, - engineSelection = b[1] as EngineSelection, + engineSelection = b[2] as EngineSelection, uptimeLabel = uptimeLabel(vmState, tick), - phoneIp = NetworkUtils.localIpv4(context), - vmRamMb = a[1] as Int, - vmCpus = a[2] as Int, - storageSizeGb = a[3] as Int, - bandwidthMbps = a[4] as Int, - loadBalanceEnabled = b[0] as Boolean, - portForwardCount = (b[2] as List<*>).size, - vmLoadPercent = c[0] as Float?, - vmLoadHistory = c[1] as List, + phoneIp = d[2] as String, + vmRamMb = a[2] as Int, + vmCpus = a[3] as Int, + storageSizeGb = a[4] as Int, + bandwidthMbps = b[0] as Int, + loadBalanceEnabled = b[1] as Boolean, + portForwardCount = (b[3] as List<*>).size, + vmLoadPercent = c[1] as Float?, + phoneCpuPercent = c[4] as Float?, + netInBps = d[3] as Float?, + netOutBps = d[4] as Float?, + liveTick = d[1] as Long, + lastRefreshMs = d[0] as Long, vmLoadGraphUnavailable = c[2] as String?, - metrics = b[3] as HostMetricsSnapshot, + charts = charts, + metrics = b[4] as HostMetricsSnapshot, ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(0), StatusUiState()) + + fun setLiveActive(active: Boolean) { + _liveActive.value = active + if (active) { + viewModelScope.launch { pollOnce() } + } else { + phoneCpuSampler.reset() + loadSampler.reset() + networkSampler.reset() + lastDiskBytes = 0L + _chartHistories.value = StatusChartHistories() + _phoneCpuPercent.value = null + _vmLoadPercent.value = null + _netInBps.value = null + _netOutBps.value = null + } + } init { - refreshMetrics() + _phoneIp.value = NetworkUtils.localIpv4(context) viewModelScope.launch { while (isActive) { - delay(2_000) - refreshMetrics() - sampleVmLoad() - if (engine.state.value is VmState.Running) { - _uptimeTick.value = System.currentTimeMillis() - } + if (_liveActive.value) pollOnce() + delay(POLL_MS) } } } + private suspend fun pollOnce() { + refreshMetrics() + updatePhoneCharts() + sampleVmLoad() + sampleNetwork() + _phoneIp.value = NetworkUtils.localIpv4(context) + _liveTick.value = System.currentTimeMillis() + if (engine.state.value is VmState.Running) { + _uptimeTick.value = System.currentTimeMillis() + } + } + + private fun updatePhoneCharts() { + val m = _metrics.value + val ramUsedPct = if (m.phoneTotalRamMb > 0) { + ((m.phoneTotalRamMb - m.phoneAvailRamMb).toFloat() / m.phoneTotalRamMb * 100f) + } else 0f + val storageUsedPct = if (m.phoneStorageTotalGb > 0) { + ((m.phoneStorageTotalGb - m.phoneStorageAvailGb) / m.phoneStorageTotalGb * 100.0).toFloat() + } else 0f + var h = _chartHistories.value.copy( + phoneRam = MetricHistory.append(_chartHistories.value.phoneRam, ramUsedPct), + phoneStorage = MetricHistory.append(_chartHistories.value.phoneStorage, storageUsedPct), + ) + phoneCpuSampler.samplePercent()?.let { pct -> + _phoneCpuPercent.value = pct + h = h.copy(phoneCpu = MetricHistory.append(h.phoneCpu, pct)) + } + _chartHistories.value = h + } + + private fun sampleNetwork() { + val sample = networkSampler.sample() ?: return + _netInBps.value = sample.rxBytesPerSec + _netOutBps.value = sample.txBytesPerSec + val h = _chartHistories.value + _chartHistories.value = h.copy( + netIn = MetricHistory.append(h.netIn, sample.rxBytesPerSec), + netOut = MetricHistory.append(h.netOut, sample.txBytesPerSec), + ) + } + fun refreshMetrics() { val storageImg = File(context.filesDir, "storage.img") - val rss = if (engine.state.value is VmState.Running) engine.emulatorRssMb() else null + val running = engine.state.value is VmState.Running + val rss = if (running) engine.emulatorRssMb() else null _metrics.value = HostMetrics.snapshot(context, storageImg, rss) + _lastRefreshMs.value = System.currentTimeMillis() + + val diskBytes = if (storageImg.isFile) HostMetrics.diskFootprintBytes(storageImg) else 0L + val diskDeltaKb = if (lastDiskBytes > 0L && diskBytes >= lastDiskBytes) { + ((diskBytes - lastDiskBytes) / 1024f) + } else 0f + lastDiskBytes = diskBytes + + val h = _chartHistories.value + _chartHistories.value = h.copy( + vmDisk = if (running) MetricHistory.append(h.vmDisk, diskBytes.toFloat()) else h.vmDisk, + vmDiskActivity = if (running) MetricHistory.append(h.vmDiskActivity, diskDeltaKb) else h.vmDiskActivity, + vmRam = if (rss != null) MetricHistory.append(h.vmRam, rss.toFloat()) else h.vmRam, + vmAvailability = MetricHistory.append(h.vmAvailability, if (running) 1f else 0f), + ) } private fun sampleVmLoad() { if (engine.state.value !is VmState.Running) { loadSampler.reset() _vmLoadPercent.value = null - _vmLoadHistory.value = emptyList() _vmLoadUnavailable.value = null return } @@ -150,7 +278,6 @@ class StatusViewModel @Inject constructor( if (pid == null) { loadSampler.reset() _vmLoadPercent.value = null - _vmLoadHistory.value = emptyList() _vmLoadUnavailable.value = "avf" return } @@ -158,7 +285,8 @@ class StatusViewModel @Inject constructor( _vmLoadUnavailable.value = null val pct = loadSampler.sampleCpuPercent(pid, uiState.value.vmCpus) ?: return _vmLoadPercent.value = pct - _vmLoadHistory.value = (_vmLoadHistory.value + pct).takeLast(VmLoadSampler.MAX_SAMPLES) + val h = _chartHistories.value + _chartHistories.value = h.copy(vmCpu = MetricHistory.append(h.vmCpu, pct)) } private fun defaultMetrics(): HostMetricsSnapshot { diff --git a/app/src/main/java/com/excp/podroid/util/MetricHistory.kt b/app/src/main/java/com/excp/podroid/util/MetricHistory.kt new file mode 100644 index 00000000..d7cf5e33 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/util/MetricHistory.kt @@ -0,0 +1,12 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + */ +package com.excp.podroid.util + +object MetricHistory { + const val MAX_SAMPLES = 120 // ~1 min at 500ms + + fun append(history: List, value: Float, max: Int = MAX_SAMPLES): List = + (history + value).takeLast(max) +} diff --git a/app/src/main/java/com/excp/podroid/util/NetworkRateSampler.kt b/app/src/main/java/com/excp/podroid/util/NetworkRateSampler.kt new file mode 100644 index 00000000..9bc8393c --- /dev/null +++ b/app/src/main/java/com/excp/podroid/util/NetworkRateSampler.kt @@ -0,0 +1,59 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * App UID TrafficStats deltas for Status network charts. + */ +package com.excp.podroid.util + +import android.net.TrafficStats +import android.os.Process +import android.os.SystemClock + +data class NetworkRateSample( + val rxBytesPerSec: Float, + val txBytesPerSec: Float, + val rxTotalBytes: Long, + val txTotalBytes: Long, +) + +class NetworkRateSampler( + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, + private val readBytes: () -> Pair? = { + val uid = Process.myUid() + val rx = TrafficStats.getUidRxBytes(uid) + val tx = TrafficStats.getUidTxBytes(uid) + if (rx < 0 || tx < 0) null else rx to tx + }, +) { + private var lastRx: Long? = null + private var lastTx: Long? = null + private var lastTimeMs: Long? = null + + fun reset() { + lastRx = null + lastTx = null + lastTimeMs = null + } + + fun sample(): NetworkRateSample? { + val bytes = readBytes() ?: return null + val (rx, tx) = bytes + val now = nowMs() + val prevRx = lastRx + val prevTx = lastTx + val prevTime = lastTimeMs + lastRx = rx + lastTx = tx + lastTimeMs = now + if (prevRx == null || prevTx == null || prevTime == null) return null + val dt = (now - prevTime) / 1000.0 + if (dt <= 0.0) return null + return NetworkRateSample( + rxBytesPerSec = ((rx - prevRx).coerceAtLeast(0) / dt).toFloat(), + txBytesPerSec = ((tx - prevTx).coerceAtLeast(0) / dt).toFloat(), + rxTotalBytes = rx, + txTotalBytes = tx, + ) + } +} diff --git a/app/src/main/java/com/excp/podroid/util/PhoneCpuSampler.kt b/app/src/main/java/com/excp/podroid/util/PhoneCpuSampler.kt new file mode 100644 index 00000000..b08118af --- /dev/null +++ b/app/src/main/java/com/excp/podroid/util/PhoneCpuSampler.kt @@ -0,0 +1,60 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Samples phone-wide CPU use from /proc/stat for Status charts. + */ +package com.excp.podroid.util + +import android.os.SystemClock +import java.io.File + +class PhoneCpuSampler( + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, + private val readTotals: () -> Pair? = { readCpuTotals() }, +) { + private var lastIdle: Long? = null + private var lastTotal: Long? = null + private var lastTimeMs: Long? = null + + fun reset() { + lastIdle = null + lastTotal = null + lastTimeMs = null + } + + /** Phone CPU busy percent 0–100, or null until a second sample exists. */ + fun samplePercent(): Float? { + val totals = readTotals() ?: return null + val (idle, total) = totals + val now = nowMs() + val prevIdle = lastIdle + val prevTotal = lastTotal + lastIdle = idle + lastTotal = total + lastTimeMs = now + if (prevIdle == null || prevTotal == null) return null + + val dIdle = (idle - prevIdle).coerceAtLeast(0) + val dTotal = (total - prevTotal).coerceAtLeast(0) + if (dTotal <= 0) return null + val busy = 1.0 - (dIdle.toDouble() / dTotal.toDouble()) + return (busy * 100.0).toFloat().coerceIn(0f, 100f) + } + + companion object { + fun readCpuTotals(): Pair? { + return try { + val line = File("/proc/stat").bufferedReader().use { it.readLine() } ?: return null + if (!line.startsWith("cpu ")) return null + val parts = line.split(Regex("\\s+")).drop(1).mapNotNull { it.toLongOrNull() } + if (parts.size < 4) return null + val idle = parts[3] + parts.getOrElse(4) { 0L } // idle + iowait + val total = parts.sum() + idle to total + } catch (_: Exception) { + null + } + } + } +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 200ea1a6..dba33e10 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -125,14 +125,39 @@ 状态监控 + 手机 + 虚拟机镜像 手机 虚拟机 + 详情 + 详情 平均负载(1 / 5 / 15 分钟) 虚拟机内存(已分配) 模拟器占用 %1$s 虚拟机磁盘镜像 + 错误 - 此页面打开时每 2 秒自动刷新。 + 正在采集实时数据… + 实时 + 每秒更新 2 次 + 最近约 1 分钟 + 手机 + Podroid 虚拟机 + CPU(平均) + 内存(手机) + 存储(手机) + 网络(总量) + 网络入站 + 网络出站 + CPU(平均) + 内存(模拟器) + 磁盘字节(总量) + 磁盘活动 + 虚拟机可用性 + CPU 使用率(平均) + 模拟器 RSS + 磁盘增长 + 虚拟机可用性(平均) 虚拟机 CPU 负载 当前:%1$d%% 正在采集数据… @@ -140,7 +165,7 @@ 虚拟机未运行 负载图表仅在 QEMU 后端且虚拟机运行时可用 根据手机上模拟器进程的 CPU 占用计算,并按虚拟机分配的核心数归一化。 - 最近约 2 分钟 · 0%(空闲)→ 100%(全部核心繁忙) + 最近约 1 分钟 · 0%(空闲)→ 100%(全部核心繁忙) 容器备份 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a32b508..37e2038a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,14 +125,39 @@ Status + Phone + VM image Phone Virtual machine + Details + Details Load average (1 / 5 / 15 min) VM RAM (allocated) Emulator using %1$s VM disk image + Error - Refreshes every 2 seconds while this screen is open. + Collecting live metrics… + LIVE + 2 updates per second + Last ~1 min + Phone + Podroid VM + CPU (average) + Memory (phone) + Storage (phone) + Network (total) + Network In + Network Out + CPU (average) + Memory (emulator) + Disk bytes (total) + Disk activity + VM availability + Percentage CPU (Avg) + Emulator RSS + Disk growth + VM Availability (Avg) VM CPU load Current: %1$d%% Collecting samples… @@ -140,7 +165,7 @@ VM is not running Load graph is available on the QEMU backend while the VM is running Tracks the emulator process CPU use on your phone, normalized to the VM\'s allocated cores. - Last ~2 min · 0% (idle) → 100% (all VM cores busy) + Last ~1 min · 0% (idle) → 100% (all VM cores busy) Container backup