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(),