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
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user