Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
cf893585b1
commit
afa82ceec2
10 files changed
+661
No files matched your search
@@ -0,0 +1,254 @@
|
||||
package com.m57.hermescontrol.ui.process
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.data.model.ProcessInfo
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
|
||||
@Composable
|
||||
fun ProcessesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: ProcessesViewModel = viewModel { ProcessesViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
var killTarget by remember { mutableStateOf<ProcessInfo?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.sessionId != null) viewModel.load()
|
||||
}
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_processes)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.load() },
|
||||
modifier = modifier,
|
||||
) {
|
||||
when {
|
||||
state.sessionId == null ->
|
||||
EmptyState(
|
||||
title = stringResource(R.string.processes_no_session_title),
|
||||
subtitle = stringResource(R.string.processes_no_session_desc),
|
||||
icon = Icons.Filled.Memory,
|
||||
)
|
||||
|
||||
state.isLoading && state.processes.isEmpty() ->
|
||||
LoadingState()
|
||||
|
||||
state.errorMessage != null ->
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: stringResource(R.string.error_unknown),
|
||||
onRetry = { viewModel.load() },
|
||||
)
|
||||
|
||||
state.processes.isEmpty() ->
|
||||
EmptyState(
|
||||
title = stringResource(R.string.processes_empty_title),
|
||||
subtitle = stringResource(R.string.processes_empty_desc),
|
||||
icon = Icons.Filled.Memory,
|
||||
)
|
||||
|
||||
else ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
items(state.processes, key = { it.sessionId }) { process ->
|
||||
ProcessCard(
|
||||
process = process,
|
||||
isKilling = state.killingId == process.sessionId,
|
||||
onKill = { killTarget = process },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
killTarget?.let { process ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { killTarget = null },
|
||||
title = { Text(stringResource(R.string.processes_kill_title)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.processes_kill_desc,
|
||||
process.title,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.kill(process.sessionId)
|
||||
killTarget = null
|
||||
},
|
||||
) { Text(stringResource(R.string.processes_kill_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { killTarget = null }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessCard(
|
||||
process: ProcessInfo,
|
||||
isKilling: Boolean,
|
||||
onKill: () -> Unit,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = process.statusIcon(),
|
||||
contentDescription = null,
|
||||
tint = process.statusColor(statusColors),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = process.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = process.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (process.isRunning) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.processes_running_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.success,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (process.isRunning) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (isKilling) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = onKill,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = stringResource(R.string.processes_kill_cd),
|
||||
tint = statusColors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ProcessInfo.subtitle: String
|
||||
get() {
|
||||
val parts = mutableListOf<String>()
|
||||
pid?.let { parts += "pid $it" }
|
||||
uptimeSeconds?.let { parts += formatUptime(it) }
|
||||
if (!isRunning) {
|
||||
exitCode?.let { parts += "exit $it" }
|
||||
}
|
||||
cwd?.let { parts += it }
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun formatUptime(seconds: Int): String {
|
||||
val m = seconds / 60
|
||||
val s = seconds % 60
|
||||
return when {
|
||||
m <= 0 -> "${s}s"
|
||||
m < 60 -> "${m}m ${s}s"
|
||||
else -> {
|
||||
val h = m / 60
|
||||
"${h}h ${m % 60}m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProcessInfo.statusIcon(): ImageVector = if (isRunning) Icons.Filled.PlayArrow else Icons.Filled.Memory
|
||||
|
||||
@Composable
|
||||
private fun ProcessInfo.statusColor(statusColors: com.m57.hermescontrol.theme.HermesStatusColors): Color =
|
||||
if (isRunning) statusColors.success else statusColors.info
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.m57.hermescontrol.ui.process
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.ProcessInfo
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ProcessesUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val processes: List<ProcessInfo> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
/** The process id currently being killed (drives the in-row spinner). */
|
||||
val killingId: String? = null,
|
||||
/** The active session id the list is scoped to (null = no chat session yet). */
|
||||
val sessionId: String? = null,
|
||||
)
|
||||
|
||||
class ProcessesViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ProcessesUiState())
|
||||
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
// Keep the session id in sync and auto-load whenever it becomes known.
|
||||
viewModelScope.launch {
|
||||
ActiveSessionHolder.activeSessionId.collect { sid ->
|
||||
_uiState.update { it.copy(sessionId = sid) }
|
||||
if (sid != null) {
|
||||
load()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
processes = emptyList(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull the active session's process snapshot from the gateway. */
|
||||
fun load() {
|
||||
val sessionId =
|
||||
ActiveSessionHolder.activeSessionId.value
|
||||
?: run {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
processes = emptyList(),
|
||||
errorMessage = "Open a chat session first — process list is session-scoped.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROCESS_LIST,
|
||||
mapOf("session_id" to sessionId),
|
||||
).await()
|
||||
val processes = parseProcessList(result)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
processes = processes,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Could not load processes: ${e.message ?: e.javaClass.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill a single process by id, then refresh the list. */
|
||||
fun kill(processId: String) {
|
||||
val sessionId =
|
||||
ActiveSessionHolder.activeSessionId.value
|
||||
?: run {
|
||||
_uiState.update { it.copy(toastMessage = "No active session — cannot kill.") }
|
||||
return
|
||||
}
|
||||
if (_uiState.value.killingId != null) return // one kill at a time
|
||||
|
||||
_uiState.update { it.copy(killingId = processId) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROCESS_KILL,
|
||||
mapOf(
|
||||
"process_id" to processId,
|
||||
"session_id" to sessionId,
|
||||
),
|
||||
).await()
|
||||
_uiState.update { it.copy(killingId = null, toastMessage = "Process killed") }
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
killingId = null,
|
||||
toastMessage = "Kill failed: ${e.message ?: e.javaClass.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `process.list` RPC result (`{ "processes": [ {…} ] }`) into
|
||||
* [ProcessInfo]s. The result is a `kotlinx` `toAny()` map (numbers arrive as
|
||||
* Double/Long/Int, booleans as Boolean) — see [HermesWsClient].
|
||||
*/
|
||||
private fun parseProcessList(result: Any?): List<ProcessInfo> {
|
||||
val map = result as? Map<*, *> ?: return emptyList()
|
||||
val rawList = map["processes"] as? List<*> ?: return emptyList()
|
||||
return rawList.mapNotNull { entry ->
|
||||
val proc = entry as? Map<*, *> ?: return@mapNotNull null
|
||||
ProcessInfo.fromMap(proc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<*, *>.str(key: String): String? = (this[key] as? String)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun Map<*, *>.intOrNull(key: String): Int? = (this[key] as? Number)?.toInt()
|
||||
|
||||
private fun Map<*, *>.bool(key: String): Boolean = (this[key] as? Boolean) ?: false
|
||||
|
||||
/** Build a [ProcessInfo] from a raw `process.list` entry map. */
|
||||
fun ProcessInfo.Companion.fromMap(raw: Map<*, *>): ProcessInfo? {
|
||||
val m = raw.mapKeys { it.key.toString() }
|
||||
val sessionId = m.str("session_id") ?: return null
|
||||
return ProcessInfo(
|
||||
sessionId = sessionId,
|
||||
command = m.str("command"),
|
||||
cwd = m.str("cwd"),
|
||||
pid = m.intOrNull("pid"),
|
||||
startedAt = m.str("started_at"),
|
||||
uptimeSeconds = m.intOrNull("uptime_seconds"),
|
||||
status = m.str("status"),
|
||||
outputPreview = m.str("output_preview"),
|
||||
outputTail = m.str("output_tail"),
|
||||
exitCode = m.intOrNull("exit_code"),
|
||||
sessionScoped = m.bool("session_scoped"),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user