From 85087c70c7517669e8167a816fa15d4586bd985c Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:00:08 +0300 Subject: [PATCH] feat: surface /api/status memory+disk pressure on connect/status screens (#903) (#908) Co-authored-by: M57 --- .../data/model/StatusResponse.kt | 34 +++++ .../hermescontrol/ui/common/PressureBanner.kt | 133 ++++++++++++++++++ .../hermescontrol/ui/connect/ConnectScreen.kt | 7 + .../ui/connect/ConnectViewModel.kt | 34 ++++- .../hermescontrol/ui/gateway/GatewayScreen.kt | 6 + app/src/main/res/values-zh/strings.xml | 6 + app/src/main/res/values/strings.xml | 8 ++ .../data/model/ModelSerializationTest.kt | 67 +++++++++ .../ui/common/PressureBannerTest.kt | 108 ++++++++++++++ 9 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/common/PressureBannerTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt index f78fabe4..39a108fe 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt @@ -8,6 +8,8 @@ data class StatusResponse( val active_sessions: Int? = null, val auth_required: Boolean? = null, val gateway_platforms: Map? = null, + val memory: MemoryPressureStatus? = null, + val disk: DiskPressureStatus? = null, ) @Serializable @@ -15,3 +17,35 @@ data class PlatformStatus( val state: String? = null, val error_code: String? = null, ) + +/** + * Host memory-pressure rollup from `GET /api/status` (backend NS-656). + * Advisory only — deliberately NOT folded into the endpoint's `overall` + * verdict. `pressure` is `ok` | `elevated` | `critical` | `unknown`; + * `unknown` means the sample could not be read (or is stale), NOT "fine". + */ +@Serializable +data class MemoryPressureStatus( + val pressure: String? = null, + val gateway_rss_mb: Int? = null, + val system_total_mb: Int? = null, + val system_available_mb: Int? = null, + val swap_used_mb: Int? = null, + val sampled_at: String? = null, + val last_boot_unclean: Boolean? = null, + val last_boot_suspected_oom: Boolean? = null, + val boot_id: String? = null, +) + +/** + * Host disk-usage rollup from `GET /api/status` (backend NS-656). + * Same advisory semantics as [MemoryPressureStatus]: coarse MB numbers + * plus a `pressure` enum, never a liveness verdict. + */ +@Serializable +data class DiskPressureStatus( + val pressure: String? = null, + val total_mb: Int? = null, + val free_mb: Int? = null, + val used_percent: Double? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt new file mode 100644 index 00000000..aaef28d4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt @@ -0,0 +1,133 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.DiskPressureStatus +import com.m57.hermescontrol.data.model.MemoryPressureStatus +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.onColorFor + +/** + * Worst-first severity of the host-health advisory (mirrors the desktop + * `MemoryPressureBanner` trigger order, issue #903): disk critical beats + * memory critical (imminent data loss beats imminent restart), and a + * suspected-OOM restart is a post-mortem that beats plain elevated states. + */ +enum class PressureTrigger { + DISK_CRITICAL, + MEMORY_CRITICAL, + OOM_RESTART, + DISK_ELEVATED, + MEMORY_ELEVATED, +} + +/** + * Picks the advisory to surface from the `/api/status` memory/disk blocks. + * Returns `null` when there is nothing to warn about. `unknown` pressure is + * absence of evidence, not recovery — it never triggers (and never clears) + * a banner, same as the desktop banner. + */ +internal fun selectPressureTrigger( + memory: MemoryPressureStatus?, + disk: DiskPressureStatus?, +): PressureTrigger? = + when { + disk?.pressure == "critical" -> PressureTrigger.DISK_CRITICAL + memory?.pressure == "critical" -> PressureTrigger.MEMORY_CRITICAL + memory?.last_boot_suspected_oom == true -> PressureTrigger.OOM_RESTART + disk?.pressure == "elevated" -> PressureTrigger.DISK_ELEVATED + memory?.pressure == "elevated" -> PressureTrigger.MEMORY_ELEVATED + else -> null + } + +/** + * Small advisory banner for host resource trouble (issue #903): renders when + * the backend's `/api/status` reports memory/disk pressure, so an OOM-thrashing + * or disk-full host explains itself instead of looking like a dead agent. + * Advisory only — it never gates anything and never blocks the UI. + */ +@Composable +fun PressureBanner( + memory: MemoryPressureStatus?, + disk: DiskPressureStatus?, + modifier: Modifier = Modifier, +) { + val trigger = selectPressureTrigger(memory, disk) ?: return + val statusColors = LocalHermesStatusColors.current + val critical = + trigger == PressureTrigger.DISK_CRITICAL || + trigger == PressureTrigger.MEMORY_CRITICAL + val bgColor = + if (critical) { + statusColors.errorContainer + } else { + statusColors.warningContainer + } + val fgColor = onColorFor(bgColor) + + val message = + when (trigger) { + PressureTrigger.DISK_CRITICAL -> + stringResource(R.string.pressure_banner_disk_critical) + + diskFreeSuffix(disk) + + PressureTrigger.DISK_ELEVATED -> + stringResource(R.string.pressure_banner_disk_elevated) + + diskFreeSuffix(disk) + + PressureTrigger.MEMORY_CRITICAL -> + stringResource(R.string.pressure_banner_memory_critical) + + PressureTrigger.OOM_RESTART -> + stringResource(R.string.pressure_banner_memory_oom) + + PressureTrigger.MEMORY_ELEVATED -> + stringResource(R.string.pressure_banner_memory_elevated) + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = bgColor, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = fgColor, + modifier = Modifier.size(16.dp), + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = fgColor, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun diskFreeSuffix(disk: DiskPressureStatus?): String { + val freeMb = disk?.free_mb ?: return "" + return stringResource(R.string.pressure_banner_disk_free_suffix, freeMb) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index 31b68cd6..bddc876f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.PressureBanner @Composable fun ConnectScreen( @@ -354,6 +355,12 @@ fun ConnectScreen( } } } + // Host-health advisory (issue #903): memory/disk pressure from + // the /api/status probe — explains a struggling host before or + // after a failed connect attempt. + state.status?.let { status -> + PressureBanner(memory = status.memory, disk = status.disk) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index 245a03bf..9c7722cc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.resolveBaseUrl import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.HealthStatus +import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.NetworkError @@ -33,6 +34,7 @@ data class ConnectUiState( val profiles: List = emptyList(), val selectedProfile: ConnectionProfile? = null, val health: HealthStatus? = null, + val status: StatusResponse? = null, ) class ConnectViewModel( @@ -61,6 +63,7 @@ class ConnectViewModel( ) } loadHealth() + loadStatus() } /** @@ -84,6 +87,28 @@ class ConnectViewModel( } } + /** + * Best-effort `/api/status` probe (issue #903): surfaces the host + * memory/disk pressure banner on the connection screen. Fired on screen + * load (saved credentials) and after a failed connect, so an OOM-thrashing + * or disk-full host explains itself instead of looking like a dead agent. + * Failures are silent (status stays null and the screen shows no banner). + */ + fun loadStatus() { + val state = _uiState.value + val endpoint = runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull() + if (endpoint == null || state.token.isBlank()) return + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.createTempService(endpoint.baseUrl.toString(), state.token).getStatus() + } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(status = result.data) } + } + } + } + fun onProfileNameChange(value: String) { _uiState.update { it.copy(profileName = value, errorMessage = null) } } @@ -180,7 +205,12 @@ class ConnectViewModel( } ApiClient.rebuild() _uiState.update { - it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null) + it.copy( + isConnecting = false, + connectionSuccess = true, + errorMessage = null, + status = result.data, + ) } } @@ -282,6 +312,8 @@ class ConnectViewModel( } } _uiState.update { it.copy(isConnecting = false, errorMessage = msg) } + // Best-effort: explain a failing host via its pressure rollup. + loadStatus() } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt index 16e1f3b6..8fdd09d0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt @@ -39,6 +39,7 @@ import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.PressureBanner import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect @@ -108,6 +109,11 @@ fun GatewayScreen( ) { val isRunning = status?.gateway_running == true + // Host-health advisory (issue #903): memory/disk pressure + status?.let { s -> + PressureBanner(memory = s.memory, disk = s.disk) + } + // Status Overview Card Card( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e61135fe..0ce2665a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -699,6 +699,12 @@ 停止 重启 活动平台 + 代理磁盘几乎已满 — 新消息、记忆和设置可能无法保存 + 代理磁盘正在填满 — 建议清理旧会话或扩展存储 + · 剩余 %1$d MB + 代理内存即将耗尽,可能重启 + 代理意外重启 — 很可能是内存不足导致 + 代理内存不足 没有定时任务 Hermes 的定时任务将显示在这里。 启用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b68bbf31..104f57ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -793,6 +793,14 @@ Restart Active Platforms + + Agent\'s disk is almost full — new messages, memories, and settings may fail to save + Agent\'s disk is filling up — consider clearing old sessions or expanding storage + · %1$d MB free + Agent is almost out of memory and may restart + Agent restarted unexpectedly — most likely it ran out of memory + Agent is running low on memory + No cron jobs Scheduled tasks from Hermes will appear here. diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index 54485518..8cad9d32 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -328,6 +328,8 @@ class ModelSerializationTest { assertNull(response.active_sessions) assertNull(response.auth_required) assertNull(response.gateway_platforms) + assertNull(response.memory) + assertNull(response.disk) } @Test @@ -353,6 +355,71 @@ class ModelSerializationTest { assertNull(iosPlatform.error_code) } + @Test + fun testStatusResponseDeserialization_memoryAndDiskBlocks() { + val jsonStr = + """ + { + "version": "1.0.0", + "memory": { + "pressure": "elevated", + "gateway_rss_mb": 512, + "system_total_mb": 8192, + "system_available_mb": 900, + "swap_used_mb": 2048, + "sampled_at": "2026-08-14T19:00:00+00:00", + "last_boot_unclean": true, + "last_boot_suspected_oom": false, + "boot_id": "2026-08-14T18:00:00+00:00" + }, + "disk": { + "pressure": "critical", + "total_mb": 512000, + "free_mb": 200, + "used_percent": 99.9 + } + } + """.trimIndent() + val response = json.decodeFromString(jsonStr) + assertEquals("elevated", response.memory?.pressure) + assertEquals(512, response.memory?.gateway_rss_mb) + assertEquals(8192, response.memory?.system_total_mb) + assertEquals(900, response.memory?.system_available_mb) + assertEquals(2048, response.memory?.swap_used_mb) + assertEquals(true, response.memory?.last_boot_unclean) + assertEquals(false, response.memory?.last_boot_suspected_oom) + assertEquals("2026-08-14T18:00:00+00:00", response.memory?.boot_id) + assertEquals("critical", response.disk?.pressure) + assertEquals(512000, response.disk?.total_mb) + assertEquals(200, response.disk?.free_mb) + assertEquals(99.9, response.disk?.used_percent ?: 0.0, 0.01) + } + + @Test + fun testStatusResponseDeserialization_pressureUnknownDegrades() { + val jsonStr = + """ + { + "memory": { + "pressure": "unknown", + "gateway_rss_mb": null, + "last_boot_unclean": false, + "last_boot_suspected_oom": false + }, + "disk": { + "pressure": "unknown" + } + } + """.trimIndent() + val response = json.decodeFromString(jsonStr) + assertEquals("unknown", response.memory?.pressure) + assertNull(response.memory?.system_total_mb) + assertEquals(false, response.memory?.last_boot_unclean) + assertEquals("unknown", response.disk?.pressure) + assertNull(response.disk?.total_mb) + assertNull(response.disk?.free_mb) + } + @Test fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() { val jsonStr = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/PressureBannerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/PressureBannerTest.kt new file mode 100644 index 00000000..5e6fc309 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/PressureBannerTest.kt @@ -0,0 +1,108 @@ +package com.m57.hermescontrol.ui.common + +import com.m57.hermescontrol.data.model.DiskPressureStatus +import com.m57.hermescontrol.data.model.MemoryPressureStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PressureBannerTest { + private fun mem( + pressure: String? = null, + suspectedOom: Boolean? = null, + ) = MemoryPressureStatus( + pressure = pressure, + last_boot_suspected_oom = suspectedOom, + ) + + private fun disk(pressure: String? = null) = DiskPressureStatus(pressure = pressure) + + @Test + fun noTrigger_whenOkUnknownOrNull() { + assertNull(selectPressureTrigger(null, null)) + assertNull(selectPressureTrigger(mem("ok"), disk("ok"))) + assertNull(selectPressureTrigger(mem("unknown"), disk("unknown"))) + assertNull(selectPressureTrigger(mem(), disk())) + } + + @Test + fun diskCritical_outranksEverything() { + assertEquals( + PressureTrigger.DISK_CRITICAL, + selectPressureTrigger(mem("critical"), disk("critical")), + ) + assertEquals( + PressureTrigger.DISK_CRITICAL, + selectPressureTrigger(mem("critical", suspectedOom = true), disk("critical")), + ) + assertEquals( + PressureTrigger.DISK_CRITICAL, + selectPressureTrigger(mem("elevated"), disk("critical")), + ) + } + + @Test + fun memoryCritical_outranksOomAndElevated() { + assertEquals( + PressureTrigger.MEMORY_CRITICAL, + selectPressureTrigger(mem("critical", suspectedOom = true), disk("elevated")), + ) + assertEquals( + PressureTrigger.MEMORY_CRITICAL, + selectPressureTrigger(mem("critical"), disk("elevated")), + ) + } + + @Test + fun oomRestart_shownEvenWhenPressureOk() { + assertEquals( + PressureTrigger.OOM_RESTART, + selectPressureTrigger(mem("ok", suspectedOom = true), disk("ok")), + ) + assertEquals( + PressureTrigger.OOM_RESTART, + selectPressureTrigger(mem("unknown", suspectedOom = true), disk("unknown")), + ) + } + + @Test + fun oomRestart_outranksElevatedButNotCritical() { + assertEquals( + PressureTrigger.OOM_RESTART, + selectPressureTrigger(mem("elevated", suspectedOom = true), disk("elevated")), + ) + assertEquals( + PressureTrigger.MEMORY_CRITICAL, + selectPressureTrigger(mem("critical", suspectedOom = true), disk("elevated")), + ) + assertEquals( + PressureTrigger.DISK_CRITICAL, + selectPressureTrigger(mem("elevated", suspectedOom = true), disk("critical")), + ) + } + + @Test + fun diskElevated_outranksMemoryElevated() { + assertEquals( + PressureTrigger.DISK_ELEVATED, + selectPressureTrigger(mem("elevated"), disk("elevated")), + ) + } + + @Test + fun memoryElevated_shownWhenOnlyTrigger() { + assertEquals( + PressureTrigger.MEMORY_ELEVATED, + selectPressureTrigger(mem("elevated"), disk("ok")), + ) + assertEquals( + PressureTrigger.DISK_ELEVATED, + selectPressureTrigger(mem("ok"), disk("elevated")), + ) + } + + @Test + fun oomFlagFalse_doesNotTrigger() { + assertNull(selectPressureTrigger(mem("ok", suspectedOom = false), disk("ok"))) + } +}