Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
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 active_sessions: Int? = null,
|
||||||
val auth_required: Boolean? = null,
|
val auth_required: Boolean? = null,
|
||||||
val gateway_platforms: Map<String, PlatformStatus?>? = null,
|
val gateway_platforms: Map<String, PlatformStatus?>? = null,
|
||||||
|
val memory: MemoryPressureStatus? = null,
|
||||||
|
val disk: DiskPressureStatus? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -15,3 +17,35 @@ data class PlatformStatus(
|
|||||||
val state: String? = null,
|
val state: String? = null,
|
||||||
val error_code: 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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.ui.common.PressureBanner
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(
|
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.config.resolveBaseUrl
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.model.HealthStatus
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||||
import com.m57.hermescontrol.data.remote.NetworkError
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
@@ -33,6 +34,7 @@ data class ConnectUiState(
|
|||||||
val profiles: List<ConnectionProfile> = emptyList(),
|
val profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfile: ConnectionProfile? = null,
|
val selectedProfile: ConnectionProfile? = null,
|
||||||
val health: HealthStatus? = null,
|
val health: HealthStatus? = null,
|
||||||
|
val status: StatusResponse? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ConnectViewModel(
|
class ConnectViewModel(
|
||||||
@@ -61,6 +63,7 @@ class ConnectViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadHealth()
|
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) {
|
fun onProfileNameChange(value: String) {
|
||||||
_uiState.update { it.copy(profileName = value, errorMessage = null) }
|
_uiState.update { it.copy(profileName = value, errorMessage = null) }
|
||||||
}
|
}
|
||||||
@@ -180,7 +205,12 @@ class ConnectViewModel(
|
|||||||
}
|
}
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
_uiState.update {
|
_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) }
|
_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.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
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.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
|
||||||
@@ -108,6 +109,11 @@ fun GatewayScreen(
|
|||||||
) {
|
) {
|
||||||
val isRunning = status?.gateway_running == true
|
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
|
// Status Overview Card
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -699,6 +699,12 @@
|
|||||||
<string name="gateway_action_stop">停止</string>
|
<string name="gateway_action_stop">停止</string>
|
||||||
<string name="gateway_action_restart">重启</string>
|
<string name="gateway_action_restart">重启</string>
|
||||||
<string name="gateway_sec_active_platforms">活动平台</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_title">没有定时任务</string>
|
||||||
<string name="cron_empty_desc">Hermes 的定时任务将显示在这里。</string>
|
<string name="cron_empty_desc">Hermes 的定时任务将显示在这里。</string>
|
||||||
<string name="cron_status_active">启用</string>
|
<string name="cron_status_active">启用</string>
|
||||||
|
|||||||
@@ -793,6 +793,14 @@
|
|||||||
<string name="gateway_action_restart">Restart</string>
|
<string name="gateway_action_restart">Restart</string>
|
||||||
<string name="gateway_sec_active_platforms">Active Platforms</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 -->
|
<!-- CronJobs Screen -->
|
||||||
<string name="cron_empty_title">No cron jobs</string>
|
<string name="cron_empty_title">No cron jobs</string>
|
||||||
<string name="cron_empty_desc">Scheduled tasks from Hermes will appear here.</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.active_sessions)
|
||||||
assertNull(response.auth_required)
|
assertNull(response.auth_required)
|
||||||
assertNull(response.gateway_platforms)
|
assertNull(response.gateway_platforms)
|
||||||
|
assertNull(response.memory)
|
||||||
|
assertNull(response.disk)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -353,6 +355,71 @@ class ModelSerializationTest {
|
|||||||
assertNull(iosPlatform.error_code)
|
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
|
@Test
|
||||||
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
|
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
|
||||||
val jsonStr =
|
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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user