From 8ca4599c6ac0d2b052b8b2e1298d42e71389a729 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Tue, 16 Jun 2026 00:52:36 +0300 Subject: [PATCH] fixes --- .../data/model/DoctorResponse.kt | 11 +-- .../hermescontrol/data/model/EnvVarConfig.kt | 28 ++++++++ .../m57/hermescontrol/data/model/Kanban.kt | 33 ++++++--- .../data/model/MessagingPlatform.kt | 21 +++++- .../hermescontrol/data/model/PluginInfo.kt | 10 ++- .../data/remote/HermesApiService.kt | 71 ++++++++++++------- .../ui/channels/ChannelsScreen.kt | 62 ++++++++++------ .../ui/channels/ChannelsViewModel.kt | 20 ++++-- .../ui/kanban/KanbanViewModel.kt | 39 ++++++---- .../m57/hermescontrol/ui/keys/KeysScreen.kt | 66 ++++++++++++++--- .../hermescontrol/ui/keys/KeysViewModel.kt | 53 ++++++++++---- .../ui/plugins/PluginsViewModel.kt | 11 ++- .../hermescontrol/ui/system/SystemScreen.kt | 63 +++++++--------- .../m57/hermescontrol/E2eIntegrationTest.kt | 40 ++++++----- 14 files changed, 362 insertions(+), 166 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/EnvVarConfig.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/DoctorResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/DoctorResponse.kt index 50ff9646..980b6afe 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/DoctorResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/DoctorResponse.kt @@ -1,12 +1,7 @@ package com.m57.hermescontrol.data.model data class DoctorResponse( - val status: String, - val reports: List, -) - -data class DoctorReport( - val category: String, - val status: String, - val message: String, + val ok: Boolean, + val pid: Int?, + val name: String?, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/EnvVarConfig.kt b/app/src/main/java/com/m57/hermescontrol/data/model/EnvVarConfig.kt new file mode 100644 index 00000000..0c02532b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/EnvVarConfig.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.data.model + +import com.google.gson.annotations.SerializedName + +data class EnvVarConfig( + @SerializedName("is_set") val isSet: Boolean, + @SerializedName("redacted_value") val redactedValue: String?, + val description: String?, + val url: String?, + val category: String?, + @SerializedName("is_password") val isPassword: Boolean, +) + +data class EnvVarRevealRequest( + val key: String, + val profile: String? = null, +) + +data class EnvVarRevealResponse( + val key: String, + val value: String, +) + +data class EnvVarUpdate( + val key: String, + val value: String, + val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt index 91e4292e..a3f0212a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt @@ -1,27 +1,38 @@ package com.m57.hermescontrol.data.model +import com.google.gson.annotations.SerializedName + data class KanbanBoard( - val id: String, + @SerializedName("slug") val id: String, val name: String, val description: String?, ) data class KanbanTask( val id: String, - val boardId: String, val title: String, - val description: String?, + @SerializedName("body") val description: String?, val status: String, - val assignedTo: String?, + @SerializedName("assignee") val assignedTo: String?, ) -data class MoveTaskRequest( - val status: String, +data class CreateTaskBody( + val title: String, + val body: String?, ) -data class CreateTaskRequest( - val boardId: String, - val title: String, - val description: String?, - val status: String, +data class KanbanColumn( + val name: String, + val tasks: List, +) + +data class KanbanBoardResponse( + val columns: List, + val assignees: List?, + val tenants: List?, +) + +data class KanbanBoardsResponse( + val boards: List, + val current: String, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt index 17377be3..624153e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt @@ -1,8 +1,27 @@ package com.m57.hermescontrol.data.model +import com.google.gson.annotations.SerializedName + data class MessagingPlatform( + val id: String, val name: String, val enabled: Boolean, val configured: Boolean, - val details: Map?, + @SerializedName("env_vars") val envVars: List? = null, +) + +data class EnvVarField( + val key: String, + val required: Boolean, + @SerializedName("is_set") val isSet: Boolean, + val description: String?, + val prompt: String?, + @SerializedName("is_password") val isPassword: Boolean, +) + +data class MessagingPlatformUpdate( + val enabled: Boolean? = null, + val env: Map = emptyMap(), + @SerializedName("clear_env") val clearEnv: List = emptyList(), + val profile: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index bf2e7c52..36196511 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -1,13 +1,21 @@ package com.m57.hermescontrol.data.model +import com.google.gson.annotations.SerializedName + data class PluginInfo( val name: String, val description: String?, val version: String?, val enabled: Boolean, - val installed: Boolean, + val installed: Boolean = true, ) data class TogglePluginRequest( val enabled: Boolean, ) + +data class AgentPluginInstallBody( + val identifier: String, + val force: Boolean = false, + val enable: Boolean = true, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 214cd34b..1c19697a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -2,17 +2,24 @@ package com.m57.hermescontrol.data.remote import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse -import com.m57.hermescontrol.data.model.CreateTaskRequest +import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.DoctorResponse +import com.m57.hermescontrol.data.model.EnvVarConfig +import com.m57.hermescontrol.data.model.EnvVarRevealRequest +import com.m57.hermescontrol.data.model.EnvVarRevealResponse +import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.KanbanBoard +import com.m57.hermescontrol.data.model.KanbanBoardResponse +import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.MessagingPlatform +import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.data.model.ModelOptionsResponse -import com.m57.hermescontrol.data.model.MoveTaskRequest import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingRevokeRequest @@ -40,9 +47,11 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path +import retrofit2.http.Query interface HermesApiService { @GET("api/status") @@ -205,72 +214,82 @@ interface HermesApiService { @GET("api/logs") suspend fun getLogs(): Response - @GET("api/plugins") + @GET("api/dashboard/plugins") suspend fun getPlugins(): Response> - @POST("api/plugins/{name}/install") + @POST("api/dashboard/agent-plugins/install") suspend fun installPlugin( - @Path("name") name: String, + @Body body: AgentPluginInstallBody, ): Response - @POST("api/plugins/{name}/uninstall") + @DELETE("api/dashboard/agent-plugins/{name}") suspend fun uninstallPlugin( @Path("name") name: String, ): Response - @POST("api/plugins/{name}/update") + @POST("api/dashboard/agent-plugins/{name}/update") suspend fun updatePlugin( @Path("name") name: String, ): Response - @PUT("api/plugins/{name}/toggle") - suspend fun togglePlugin( + @POST("api/dashboard/agent-plugins/{name}/enable") + suspend fun enablePlugin( + @Path("name") name: String, + ): Response + + @POST("api/dashboard/agent-plugins/{name}/disable") + suspend fun disablePlugin( @Path("name") name: String, - @Body body: TogglePluginRequest, ): Response @GET("api/messaging/platforms") suspend fun getMessagingPlatforms(): Response> - @POST("api/messaging/platforms/{name}/configure") + @PUT("api/messaging/platforms/{platform_id}") suspend fun configurePlatform( - @Path("name") name: String, - @Body config: Map, + @Path("platform_id") platformId: String, + @Body config: MessagingPlatformUpdate, ): Response @GET("api/env") - suspend fun getEnvVars(): Response> + suspend fun getEnvVars(): Response> @PUT("api/env") suspend fun updateEnvVar( - @Body vars: Map, + @Body body: EnvVarUpdate, ): Response @POST("api/env/reveal") - suspend fun revealEnvVars(): Response> + suspend fun revealEnvVar( + @Body request: EnvVarRevealRequest, + ): Response @POST("api/ops/backup") suspend fun triggerBackup(): Response - @GET("api/ops/doctor") + @POST("api/ops/doctor") suspend fun runDoctor(): Response @GET("api/plugins/kanban/boards") - suspend fun getKanbanBoards(): Response> + suspend fun getKanbanBoards(): Response - @GET("api/plugins/kanban/boards/{id}/tasks") - suspend fun getKanbanTasks( - @Path("id") boardId: String, - ): Response> + @GET("api/plugins/kanban/board") + suspend fun getKanbanBoard(): Response - @POST("api/plugins/kanban/tasks/{id}/move") - suspend fun moveKanbanTask( + @POST("api/plugins/kanban/boards/{slug}/switch") + suspend fun switchKanbanBoard( + @Path("slug") slug: String, + ): Response + + @PATCH("api/plugins/kanban/tasks/{id}") + suspend fun updateKanbanTask( @Path("id") taskId: String, - @Body body: MoveTaskRequest, + @Body body: Map, ): Response @POST("api/plugins/kanban/tasks") suspend fun createKanbanTask( - @Body task: CreateTaskRequest, + @Query("board") board: String?, + @Body task: CreateTaskBody, ): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt index 8def6416..d49a701e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -41,6 +42,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.data.model.MessagingPlatformUpdate @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -135,8 +137,20 @@ fun ChannelsScreen( style = MaterialTheme.typography.bodyMedium, ) } - Button(onClick = { showConfigureForm = !showConfigureForm }) { - Text(if (showConfigureForm) "Cancel" else "Configure") + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = platform.enabled, + onCheckedChange = { isChecked -> + viewModel.configurePlatform( + platform.id, + MessagingPlatformUpdate(enabled = isChecked), + ) + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = { showConfigureForm = !showConfigureForm }) { + Text(if (showConfigureForm) "Cancel" else "Configure") + } } } @@ -147,20 +161,19 @@ fun ChannelsScreen( style = MaterialTheme.typography.titleMedium, ) - val fields = - if (platform.name.lowercase() == "telegram") { - listOf("bot_token", "chat_id") - } else { - listOf("webhook_url", "channel") - } - + val envFields = platform.envVars.orEmpty() val inputValues = remember { mutableStateMapOf() } - fields.forEach { field -> + envFields.forEach { field -> OutlinedTextField( - value = inputValues[field].orEmpty(), - onValueChange = { inputValues[field] = it }, - label = { Text(field.replace("_", " ").uppercase()) }, + value = inputValues[field.key].orEmpty(), + onValueChange = { inputValues[field.key] = it }, + label = { Text(field.prompt ?: field.key) }, + placeholder = { + if (field.isSet) { + Text("******** (Already Configured)") + } + }, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(8.dp)) @@ -168,7 +181,7 @@ fun ChannelsScreen( Button( onClick = { - viewModel.configurePlatform(platform.name, inputValues.toMap()) + viewModel.configurePlatform(platform.id, inputValues.toMap()) showConfigureForm = false }, modifier = Modifier.align(Alignment.End), @@ -176,16 +189,25 @@ fun ChannelsScreen( Text("Save Settings") } } else { - platform.details?.let { details -> - if (details.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - Text(text = "Details:", style = MaterialTheme.typography.titleSmall) - details.forEach { (key, value) -> + val envFields = platform.envVars.orEmpty() + if (envFields.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "Configured Keys:", style = MaterialTheme.typography.titleSmall) + envFields.forEach { field -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { Text( - text = "$key: $value", + text = field.key, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text( + text = if (field.isSet) "Configured" else "Not set", + style = MaterialTheme.typography.bodyMedium, + color = if (field.isSet) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index d5ec8ea5..0a399801 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.channels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.MessagingPlatform +import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.data.remote.ApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -48,27 +49,34 @@ class ChannelsViewModel : ViewModel() { } fun configurePlatform( - name: String, - config: Map, + platformId: String, + update: MessagingPlatformUpdate, ) { viewModelScope.launch { try { val response = withContext(Dispatchers.IO) { - ApiClient.hermesApi.configurePlatform(name, config) + ApiClient.hermesApi.configurePlatform(platformId, update) } if (response.isSuccessful) { - _uiState.update { it.copy(toastMessage = "$name configured successfully") } + _uiState.update { it.copy(toastMessage = "$platformId configured successfully") } loadPlatforms() } else { - _uiState.update { it.copy(toastMessage = "Failed to configure $name: HTTP ${response.code()}") } + _uiState.update { it.copy(toastMessage = "Failed to configure platform: HTTP ${response.code()}") } } } catch (e: Exception) { - _uiState.update { it.copy(toastMessage = "Failed to configure $name: ${e.message}") } + _uiState.update { it.copy(toastMessage = "Failed to configure platform: ${e.message}") } } } } + fun configurePlatform( + platformId: String, + config: Map, + ) { + configurePlatform(platformId, MessagingPlatformUpdate(env = config)) + } + fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 3837b5f4..cddf044f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -2,10 +2,8 @@ package com.m57.hermescontrol.ui.kanban import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.m57.hermescontrol.data.model.CreateTaskRequest import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanTask -import com.m57.hermescontrol.data.model.MoveTaskRequest import com.m57.hermescontrol.data.remote.ApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -37,10 +35,12 @@ class KanbanViewModel : ViewModel() { ApiClient.hermesApi.getKanbanBoards() } if (response.isSuccessful) { - val boards = response.body().orEmpty() + val boards = response.body()?.boards.orEmpty() _uiState.update { it.copy(isLoading = false, boards = boards) } - if (boards.isNotEmpty()) { - selectBoard(boards.first()) + val currentSlug = response.body()?.current + val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull() + if (currentBoard != null) { + selectBoard(currentBoard) } } else { _uiState.update { @@ -65,12 +65,23 @@ class KanbanViewModel : ViewModel() { _uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) } viewModelScope.launch { try { + // Switch current board slug + withContext(Dispatchers.IO) { + ApiClient.hermesApi.switchKanbanBoard(board.id) + } + val response = withContext(Dispatchers.IO) { - ApiClient.hermesApi.getKanbanTasks(board.id) + ApiClient.hermesApi.getKanbanBoard() } if (response.isSuccessful) { - _uiState.update { it.copy(isLoading = false, tasks = response.body().orEmpty()) } + val allTasks = + response + .body() + ?.columns + ?.flatMap { it.tasks } + .orEmpty() + _uiState.update { it.copy(isLoading = false, tasks = allTasks) } } else { _uiState.update { it.copy( @@ -101,12 +112,12 @@ class KanbanViewModel : ViewModel() { val response = withContext(Dispatchers.IO) { ApiClient.hermesApi.createKanbanTask( - CreateTaskRequest( - boardId = board.id, - title = title, - description = description, - status = status, - ), + board = board.id, + task = + com.m57.hermescontrol.data.model.CreateTaskBody( + title = title, + body = description, + ), ) } if (response.isSuccessful) { @@ -142,7 +153,7 @@ class KanbanViewModel : ViewModel() { try { val response = withContext(Dispatchers.IO) { - ApiClient.hermesApi.moveKanbanTask(task.id, MoveTaskRequest(newStatus)) + ApiClient.hermesApi.updateKanbanTask(task.id, mapOf("status" to newStatus)) } if (!response.isSuccessful) { revertTaskMove(task.id, originalStatus, "Failed to move task: HTTP ${response.code()}") diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt index 3cef857e..0daea540 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt @@ -15,7 +15,11 @@ 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.Edit import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -52,7 +56,7 @@ fun KeysScreen( val context = LocalContext.current LaunchedEffect(Unit) { - viewModel.loadKeys(reveal = false) + viewModel.loadKeys() } LaunchedEffect(state.toastMessage) { @@ -77,8 +81,11 @@ fun KeysScreen( }, title = { Text("Keys & Credentials") }, actions = { - Button(onClick = { viewModel.loadKeys(reveal = !state.isRevealed) }) { - Text(if (state.isRevealed) "Hide Values" else "Reveal Values") + IconButton(onClick = { viewModel.loadKeys() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = "Refresh", + ) } }, ) @@ -108,9 +115,20 @@ fun KeysScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(state.envVars.toList()) { (key, value) -> + items(state.envVars.toList()) { (key, config) -> var isEditing by remember { mutableStateOf(false) } - var editedValue by remember { mutableStateOf(value) } + val revealedVal = state.revealedValues[key] + val isRevealed = revealedVal != null + var editedValue by remember { mutableStateOf(revealedVal ?: "") } + + val displayValue = + if (isRevealed) { + revealedVal.orEmpty() + } else if (config.isSet) { + config.redactedValue ?: "********" + } else { + "Not configured" + } Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { @@ -119,6 +137,14 @@ fun KeysScreen( style = MaterialTheme.typography.titleMedium, fontFamily = FontFamily.Monospace, ) + if (!config.description.isNullOrBlank()) { + Text( + text = config.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(vertical = 4.dp), + ) + } Spacer(modifier = Modifier.height(4.dp)) if (isEditing) { OutlinedTextField( @@ -141,7 +167,6 @@ fun KeysScreen( Spacer(modifier = Modifier.width(8.dp)) Button(onClick = { isEditing = false - editedValue = value }) { Text("Cancel") } @@ -153,13 +178,36 @@ fun KeysScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = value, + text = displayValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), ) - IconButton(onClick = { isEditing = true }) { - Text("Edit", style = MaterialTheme.typography.bodySmall) + Row { + if (config.isSet) { + IconButton(onClick = { + if (isRevealed) { + viewModel.hideKey(key) + } else { + viewModel.revealKey(key) + } + }) { + Icon( + imageVector = if (isRevealed) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = "Toggle Visibility", + ) + } + } + IconButton(onClick = { + editedValue = if (isRevealed) revealedVal.orEmpty() else "" + isEditing = true + }) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Edit", + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt index 964f6dae..bbdb46b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt @@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.keys import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.EnvVarConfig +import com.m57.hermescontrol.data.model.EnvVarRevealRequest +import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.remote.ApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -13,8 +16,8 @@ import kotlinx.coroutines.withContext data class KeysUiState( val isLoading: Boolean = false, - val envVars: Map = emptyMap(), - val isRevealed: Boolean = false, + val envVars: Map = emptyMap(), + val revealedValues: Map = emptyMap(), val errorMessage: String? = null, val toastMessage: String? = null, ) @@ -29,18 +32,13 @@ class KeysViewModel : ViewModel() { try { val response = withContext(Dispatchers.IO) { - if (reveal) { - ApiClient.hermesApi.revealEnvVars() - } else { - ApiClient.hermesApi.getEnvVars() - } + ApiClient.hermesApi.getEnvVars() } if (response.isSuccessful) { _uiState.update { it.copy( isLoading = false, envVars = response.body().orEmpty(), - isRevealed = reveal, ) } } else { @@ -57,23 +55,50 @@ class KeysViewModel : ViewModel() { } } + fun revealKey(key: String) { + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) + } + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + _uiState.update { state -> + state.copy( + revealedValues = state.revealedValues + (body.key to body.value), + ) + } + } else { + _uiState.update { it.copy(toastMessage = "Failed to reveal key: HTTP ${response.code()}") } + } + } catch (e: Exception) { + _uiState.update { it.copy(toastMessage = "Failed to reveal key: ${e.message}") } + } + } + } + + fun hideKey(key: String) { + _uiState.update { state -> + state.copy( + revealedValues = state.revealedValues.toMutableMap().apply { remove(key) }, + ) + } + } + fun updateKey( key: String, value: String, ) { viewModelScope.launch { try { - val updatedVars = - _uiState.value.envVars.toMutableMap().apply { - put(key, value) - } val response = withContext(Dispatchers.IO) { - ApiClient.hermesApi.updateEnvVar(updatedVars) + ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key, value)) } if (response.isSuccessful) { _uiState.update { it.copy(toastMessage = "Key updated successfully") } - loadKeys(_uiState.value.isRevealed) + loadKeys() } else { _uiState.update { it.copy(toastMessage = "Failed to update key: HTTP ${response.code()}") } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index f2003ade..0e3f2ba1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -66,7 +66,11 @@ class PluginsViewModel : ViewModel() { try { val response = withContext(Dispatchers.IO) { - ApiClient.hermesApi.togglePlugin(plugin.name, TogglePluginRequest(targetEnabled)) + if (targetEnabled) { + ApiClient.hermesApi.enablePlugin(plugin.name) + } else { + ApiClient.hermesApi.disablePlugin(plugin.name) + } } if (!response.isSuccessful) { revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: HTTP ${response.code()}") @@ -82,7 +86,10 @@ class PluginsViewModel : ViewModel() { try { val response = withContext(Dispatchers.IO) { - ApiClient.hermesApi.installPlugin(name) + ApiClient.hermesApi.installPlugin( + com.m57.hermescontrol.data.model + .AgentPluginInstallBody(name), + ) } if (response.isSuccessful) { _uiState.update { it.copy(toastMessage = "Plugin installed successfully") } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index dbb72a65..a63760eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -149,50 +149,37 @@ fun SystemScreen( } } - // Doctor reports + // Doctor status state.doctorReport?.let { report -> item { - Text( - text = "Doctor Diagnostic Reports (Status: ${report.status})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 4.dp), - ) - } - - items(report.reports) { diagnosis -> Card(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = diagnosis.category.uppercase(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = diagnosis.message, style = MaterialTheme.typography.bodyMedium) - } - Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.padding(16.dp)) { Text( - text = diagnosis.status.uppercase(), - color = - if (diagnosis.status.lowercase() == "ok" || - diagnosis.status.lowercase() == "success" - ) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, + text = "Doctor Diagnostics", + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Diagnostics Status: ${if (report.ok) "Running" else "Failed"}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = if (report.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + report.pid?.let { pid -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Process PID: $pid", + style = MaterialTheme.typography.bodyMedium, + ) + } + report.name?.let { name -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Process Name: $name", + style = MaterialTheme.typography.bodyMedium, + ) + } } } } diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index d5f75e08..f9819fe3 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -3,20 +3,21 @@ package com.m57.hermescontrol import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.model.CreateTaskRequest +import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CronJob -import com.m57.hermescontrol.data.model.DoctorReport import com.m57.hermescontrol.data.model.DoctorResponse +import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.KanbanBoard +import com.m57.hermescontrol.data.model.KanbanBoardResponse +import com.m57.hermescontrol.data.model.KanbanBoardsResponse +import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.MessagingPlatform -import com.m57.hermescontrol.data.model.MoveTaskRequest import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse -import com.m57.hermescontrol.data.model.TogglePluginRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService @@ -795,8 +796,7 @@ class E2eIntegrationTest { runTest { val plugin = PluginInfo("plugin-1", "Desc", "1.0", false, true) coEvery { mockApiService.getPlugins() } returns Response.success(listOf(plugin)) - coEvery { mockApiService.togglePlugin("plugin-1", TogglePluginRequest(true)) } returns - Response.success(Unit) + coEvery { mockApiService.enablePlugin("plugin-1") } returns Response.success(Unit) val viewModel = PluginsViewModel() viewModel.loadPlugins() @@ -825,7 +825,7 @@ class E2eIntegrationTest { @Test fun testChannelsConfiguration_success() = runTest { - val platform = MessagingPlatform("telegram", false, false, null) + val platform = MessagingPlatform("telegram", "Telegram", false, false, emptyList()) coEvery { mockApiService.getMessagingPlatforms() } returns Response.success(listOf(platform)) coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit) @@ -844,14 +844,19 @@ class E2eIntegrationTest { @Test fun testKeysManagement_success() = runTest { - coEvery { mockApiService.getEnvVars() } returns Response.success(mapOf("KEY1" to "value1")) + coEvery { mockApiService.getEnvVars() } returns + Response.success(mapOf("KEY1" to EnvVarConfig(true, "value1", "desc", null, null, false))) coEvery { mockApiService.updateEnvVar(any()) } returns Response.success(Unit) val viewModel = KeysViewModel() viewModel.loadKeys(reveal = false) advanceUntilIdle() - assertEquals("value1", viewModel.uiState.value.envVars["KEY1"]) + assertEquals( + "value1", + viewModel.uiState.value.envVars["KEY1"] + ?.redactedValue, + ) viewModel.updateKey("KEY1", "newValue") advanceUntilIdle() @@ -863,7 +868,7 @@ class E2eIntegrationTest { fun testSystemDiagnostics_success() = runTest { val stats = SystemStatsResponse(cpu = mapOf("percent" to 50.0), memory = mapOf("percent" to 60.0)) - val doctor = DoctorResponse("ok", listOf(DoctorReport("networking", "ok", "all good"))) + val doctor = DoctorResponse(true, 24856, "doctor") coEvery { mockApiService.getSystemStats() } returns Response.success(stats) coEvery { mockApiService.runDoctor() } returns Response.success(doctor) coEvery { mockApiService.triggerBackup() } returns Response.success(Unit) @@ -878,9 +883,9 @@ class E2eIntegrationTest { ?.cpuPercent, ) assertEquals( - "ok", + true, viewModel.uiState.value.doctorReport - ?.status, + ?.ok, ) viewModel.triggerBackup() @@ -893,10 +898,13 @@ class E2eIntegrationTest { fun testKanbanBoardAndTasks_success() = runTest { val board = KanbanBoard("board-1", "Backlog", null) - val task = KanbanTask("task-1", "board-1", "Do laundry", null, "todo", null) - coEvery { mockApiService.getKanbanBoards() } returns Response.success(listOf(board)) - coEvery { mockApiService.getKanbanTasks("board-1") } returns Response.success(listOf(task)) - coEvery { mockApiService.moveKanbanTask("task-1", MoveTaskRequest("in_progress")) } returns + val task = KanbanTask("task-1", "Do laundry", null, "todo", null) + coEvery { mockApiService.getKanbanBoards() } returns + Response.success(KanbanBoardsResponse(listOf(board), "board-1")) + coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit) + coEvery { mockApiService.getKanbanBoard() } returns + Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null)) + coEvery { mockApiService.updateKanbanTask("task-1", mapOf("status" to "in_progress")) } returns Response.success(Unit) val viewModel = KanbanViewModel()