feat: surface /api/status memory+disk pressure on connect/status screens (#903) (#908)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-15 13:00:08 +03:00
1 parent 2746d39c7f
commit 85087c70c7
9 files changed
+402 -1

No files matched your search

@@ -8,6 +8,8 @@ data class StatusResponse(
val active_sessions: Int? = null,
val auth_required: Boolean? = null,
val gateway_platforms: Map<String, PlatformStatus?>? = 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,
)
@@ -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)
}
@@ -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)
}
}
}
}
@@ -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<ConnectionProfile> = 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()
}
}
}
@@ -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(),
+6
View File
@@ -699,6 +699,12 @@
<string name="gateway_action_stop">停止</string>
<string name="gateway_action_restart">重启</string>
<string name="gateway_sec_active_platforms">活动平台</string>
<string name="pressure_banner_disk_critical">代理磁盘几乎已满 — 新消息、记忆和设置可能无法保存</string>
<string name="pressure_banner_disk_elevated">代理磁盘正在填满 — 建议清理旧会话或扩展存储</string>
<string name="pressure_banner_disk_free_suffix"> · 剩余 %1$d MB</string>
<string name="pressure_banner_memory_critical">代理内存即将耗尽,可能重启</string>
<string name="pressure_banner_memory_oom">代理意外重启 — 很可能是内存不足导致</string>
<string name="pressure_banner_memory_elevated">代理内存不足</string>
<string name="cron_empty_title">没有定时任务</string>
<string name="cron_empty_desc">Hermes 的定时任务将显示在这里。</string>
<string name="cron_status_active">启用</string>
+8
View File
@@ -793,6 +793,14 @@
<string name="gateway_action_restart">Restart</string>
<string name="gateway_sec_active_platforms">Active Platforms</string>
<!-- Host pressure banner (issue #903) -->
<string name="pressure_banner_disk_critical">Agent\'s disk is almost full — new messages, memories, and settings may fail to save</string>
<string name="pressure_banner_disk_elevated">Agent\'s disk is filling up — consider clearing old sessions or expanding storage</string>
<string name="pressure_banner_disk_free_suffix"> · %1$d MB free</string>
<string name="pressure_banner_memory_critical">Agent is almost out of memory and may restart</string>
<string name="pressure_banner_memory_oom">Agent restarted unexpectedly — most likely it ran out of memory</string>
<string name="pressure_banner_memory_elevated">Agent is running low on memory</string>
<!-- CronJobs Screen -->
<string name="cron_empty_title">No cron jobs</string>
<string name="cron_empty_desc">Scheduled tasks from Hermes will appear here.</string>
@@ -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<StatusResponse>(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<StatusResponse>(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 =
@@ -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")))
}
}