This commit is contained in:
Hy4ri committed 2026-06-16 00:52:36 +03:00
1 parent 0562906634
commit 8ca4599c6a
14 files changed
+362 -166

No files matched your search

@@ -1,12 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
data class DoctorResponse( data class DoctorResponse(
val status: String, val ok: Boolean,
val reports: List<DoctorReport>, val pid: Int?,
) val name: String?,
data class DoctorReport(
val category: String,
val status: String,
val message: String,
) )
@@ -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,
)
@@ -1,27 +1,38 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import com.google.gson.annotations.SerializedName
data class KanbanBoard( data class KanbanBoard(
val id: String, @SerializedName("slug") val id: String,
val name: String, val name: String,
val description: String?, val description: String?,
) )
data class KanbanTask( data class KanbanTask(
val id: String, val id: String,
val boardId: String,
val title: String, val title: String,
val description: String?, @SerializedName("body") val description: String?,
val status: String, val status: String,
val assignedTo: String?, @SerializedName("assignee") val assignedTo: String?,
) )
data class MoveTaskRequest( data class CreateTaskBody(
val status: String, val title: String,
val body: String?,
) )
data class CreateTaskRequest( data class KanbanColumn(
val boardId: String, val name: String,
val title: String, val tasks: List<KanbanTask>,
val description: String?, )
val status: String,
data class KanbanBoardResponse(
val columns: List<KanbanColumn>,
val assignees: List<String>?,
val tenants: List<String>?,
)
data class KanbanBoardsResponse(
val boards: List<KanbanBoard>,
val current: String,
) )
@@ -1,8 +1,27 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import com.google.gson.annotations.SerializedName
data class MessagingPlatform( data class MessagingPlatform(
val id: String,
val name: String, val name: String,
val enabled: Boolean, val enabled: Boolean,
val configured: Boolean, val configured: Boolean,
val details: Map<String, String>?, @SerializedName("env_vars") val envVars: List<EnvVarField>? = 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<String, String> = emptyMap(),
@SerializedName("clear_env") val clearEnv: List<String> = emptyList(),
val profile: String? = null,
) )
@@ -1,13 +1,21 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import com.google.gson.annotations.SerializedName
data class PluginInfo( data class PluginInfo(
val name: String, val name: String,
val description: String?, val description: String?,
val version: String?, val version: String?,
val enabled: Boolean, val enabled: Boolean,
val installed: Boolean, val installed: Boolean = true,
) )
data class TogglePluginRequest( data class TogglePluginRequest(
val enabled: Boolean, val enabled: Boolean,
) )
data class AgentPluginInstallBody(
val identifier: String,
val force: Boolean = false,
val enable: Boolean = true,
)
@@ -2,17 +2,24 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse 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.CronJob
import com.m57.hermescontrol.data.model.DoctorResponse 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.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.KanbanTask
import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.model.MessagingPlatform 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.ModelOptionsResponse
import com.m57.hermescontrol.data.model.MoveTaskRequest
import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest import com.m57.hermescontrol.data.model.PairingRevokeRequest
@@ -40,9 +47,11 @@ import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
interface HermesApiService { interface HermesApiService {
@GET("api/status") @GET("api/status")
@@ -205,72 +214,82 @@ interface HermesApiService {
@GET("api/logs") @GET("api/logs")
suspend fun getLogs(): Response<LogResponse> suspend fun getLogs(): Response<LogResponse>
@GET("api/plugins") @GET("api/dashboard/plugins")
suspend fun getPlugins(): Response<List<PluginInfo>> suspend fun getPlugins(): Response<List<PluginInfo>>
@POST("api/plugins/{name}/install") @POST("api/dashboard/agent-plugins/install")
suspend fun installPlugin( suspend fun installPlugin(
@Path("name") name: String, @Body body: AgentPluginInstallBody,
): Response<Unit> ): Response<Unit>
@POST("api/plugins/{name}/uninstall") @DELETE("api/dashboard/agent-plugins/{name}")
suspend fun uninstallPlugin( suspend fun uninstallPlugin(
@Path("name") name: String, @Path("name") name: String,
): Response<Unit> ): Response<Unit>
@POST("api/plugins/{name}/update") @POST("api/dashboard/agent-plugins/{name}/update")
suspend fun updatePlugin( suspend fun updatePlugin(
@Path("name") name: String, @Path("name") name: String,
): Response<Unit> ): Response<Unit>
@PUT("api/plugins/{name}/toggle") @POST("api/dashboard/agent-plugins/{name}/enable")
suspend fun togglePlugin( suspend fun enablePlugin(
@Path("name") name: String,
): Response<Unit>
@POST("api/dashboard/agent-plugins/{name}/disable")
suspend fun disablePlugin(
@Path("name") name: String, @Path("name") name: String,
@Body body: TogglePluginRequest,
): Response<Unit> ): Response<Unit>
@GET("api/messaging/platforms") @GET("api/messaging/platforms")
suspend fun getMessagingPlatforms(): Response<List<MessagingPlatform>> suspend fun getMessagingPlatforms(): Response<List<MessagingPlatform>>
@POST("api/messaging/platforms/{name}/configure") @PUT("api/messaging/platforms/{platform_id}")
suspend fun configurePlatform( suspend fun configurePlatform(
@Path("name") name: String, @Path("platform_id") platformId: String,
@Body config: Map<String, String>, @Body config: MessagingPlatformUpdate,
): Response<Unit> ): Response<Unit>
@GET("api/env") @GET("api/env")
suspend fun getEnvVars(): Response<Map<String, String>> suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
@PUT("api/env") @PUT("api/env")
suspend fun updateEnvVar( suspend fun updateEnvVar(
@Body vars: Map<String, String>, @Body body: EnvVarUpdate,
): Response<Unit> ): Response<Unit>
@POST("api/env/reveal") @POST("api/env/reveal")
suspend fun revealEnvVars(): Response<Map<String, String>> suspend fun revealEnvVar(
@Body request: EnvVarRevealRequest,
): Response<EnvVarRevealResponse>
@POST("api/ops/backup") @POST("api/ops/backup")
suspend fun triggerBackup(): Response<Unit> suspend fun triggerBackup(): Response<Unit>
@GET("api/ops/doctor") @POST("api/ops/doctor")
suspend fun runDoctor(): Response<DoctorResponse> suspend fun runDoctor(): Response<DoctorResponse>
@GET("api/plugins/kanban/boards") @GET("api/plugins/kanban/boards")
suspend fun getKanbanBoards(): Response<List<KanbanBoard>> suspend fun getKanbanBoards(): Response<KanbanBoardsResponse>
@GET("api/plugins/kanban/boards/{id}/tasks") @GET("api/plugins/kanban/board")
suspend fun getKanbanTasks( suspend fun getKanbanBoard(): Response<KanbanBoardResponse>
@Path("id") boardId: String,
): Response<List<KanbanTask>>
@POST("api/plugins/kanban/tasks/{id}/move") @POST("api/plugins/kanban/boards/{slug}/switch")
suspend fun moveKanbanTask( suspend fun switchKanbanBoard(
@Path("slug") slug: String,
): Response<Unit>
@PATCH("api/plugins/kanban/tasks/{id}")
suspend fun updateKanbanTask(
@Path("id") taskId: String, @Path("id") taskId: String,
@Body body: MoveTaskRequest, @Body body: Map<String, String?>,
): Response<Unit> ): Response<Unit>
@POST("api/plugins/kanban/tasks") @POST("api/plugins/kanban/tasks")
suspend fun createKanbanTask( suspend fun createKanbanTask(
@Body task: CreateTaskRequest, @Query("board") board: String?,
@Body task: CreateTaskBody,
): Response<KanbanTask> ): Response<KanbanTask>
} }
@@ -26,6 +26,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -41,6 +42,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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.data.model.MessagingPlatformUpdate
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -135,8 +137,20 @@ fun ChannelsScreen(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
Button(onClick = { showConfigureForm = !showConfigureForm }) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (showConfigureForm) "Cancel" else "Configure") 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, style = MaterialTheme.typography.titleMedium,
) )
val fields = val envFields = platform.envVars.orEmpty()
if (platform.name.lowercase() == "telegram") {
listOf("bot_token", "chat_id")
} else {
listOf("webhook_url", "channel")
}
val inputValues = remember { mutableStateMapOf<String, String>() } val inputValues = remember { mutableStateMapOf<String, String>() }
fields.forEach { field -> envFields.forEach { field ->
OutlinedTextField( OutlinedTextField(
value = inputValues[field].orEmpty(), value = inputValues[field.key].orEmpty(),
onValueChange = { inputValues[field] = it }, onValueChange = { inputValues[field.key] = it },
label = { Text(field.replace("_", " ").uppercase()) }, label = { Text(field.prompt ?: field.key) },
placeholder = {
if (field.isSet) {
Text("******** (Already Configured)")
}
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -168,7 +181,7 @@ fun ChannelsScreen(
Button( Button(
onClick = { onClick = {
viewModel.configurePlatform(platform.name, inputValues.toMap()) viewModel.configurePlatform(platform.id, inputValues.toMap())
showConfigureForm = false showConfigureForm = false
}, },
modifier = Modifier.align(Alignment.End), modifier = Modifier.align(Alignment.End),
@@ -176,16 +189,25 @@ fun ChannelsScreen(
Text("Save Settings") Text("Save Settings")
} }
} else { } else {
platform.details?.let { details -> val envFields = platform.envVars.orEmpty()
if (details.isNotEmpty()) { if (envFields.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text(text = "Details:", style = MaterialTheme.typography.titleSmall) Text(text = "Configured Keys:", style = MaterialTheme.typography.titleSmall)
details.forEach { (key, value) -> envFields.forEach { field ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text( Text(
text = "$key: $value", text = field.key,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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,
)
} }
} }
} }
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.channels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -48,27 +49,34 @@ class ChannelsViewModel : ViewModel() {
} }
fun configurePlatform( fun configurePlatform(
name: String, platformId: String,
config: Map<String, String>, update: MessagingPlatformUpdate,
) { ) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val response = val response =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.configurePlatform(name, config) ApiClient.hermesApi.configurePlatform(platformId, update)
} }
if (response.isSuccessful) { if (response.isSuccessful) {
_uiState.update { it.copy(toastMessage = "$name configured successfully") } _uiState.update { it.copy(toastMessage = "$platformId configured successfully") }
loadPlatforms() loadPlatforms()
} else { } 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) { } 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<String, String>,
) {
configurePlatform(platformId, MessagingPlatformUpdate(env = config))
}
fun clearToast() { fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
@@ -2,10 +2,8 @@ package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CreateTaskRequest
import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.MoveTaskRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,10 +35,12 @@ class KanbanViewModel : ViewModel() {
ApiClient.hermesApi.getKanbanBoards() ApiClient.hermesApi.getKanbanBoards()
} }
if (response.isSuccessful) { if (response.isSuccessful) {
val boards = response.body().orEmpty() val boards = response.body()?.boards.orEmpty()
_uiState.update { it.copy(isLoading = false, boards = boards) } _uiState.update { it.copy(isLoading = false, boards = boards) }
if (boards.isNotEmpty()) { val currentSlug = response.body()?.current
selectBoard(boards.first()) val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
if (currentBoard != null) {
selectBoard(currentBoard)
} }
} else { } else {
_uiState.update { _uiState.update {
@@ -65,12 +65,23 @@ class KanbanViewModel : ViewModel() {
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) } _uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { try {
// Switch current board slug
withContext(Dispatchers.IO) {
ApiClient.hermesApi.switchKanbanBoard(board.id)
}
val response = val response =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.getKanbanTasks(board.id) ApiClient.hermesApi.getKanbanBoard()
} }
if (response.isSuccessful) { 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 { } else {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -101,12 +112,12 @@ class KanbanViewModel : ViewModel() {
val response = val response =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.createKanbanTask( ApiClient.hermesApi.createKanbanTask(
CreateTaskRequest( board = board.id,
boardId = board.id, task =
title = title, com.m57.hermescontrol.data.model.CreateTaskBody(
description = description, title = title,
status = status, body = description,
), ),
) )
} }
if (response.isSuccessful) { if (response.isSuccessful) {
@@ -142,7 +153,7 @@ class KanbanViewModel : ViewModel() {
try { try {
val response = val response =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.moveKanbanTask(task.id, MoveTaskRequest(newStatus)) ApiClient.hermesApi.updateKanbanTask(task.id, mapOf("status" to newStatus))
} }
if (!response.isSuccessful) { if (!response.isSuccessful) {
revertTaskMove(task.id, originalStatus, "Failed to move task: HTTP ${response.code()}") revertTaskMove(task.id, originalStatus, "Failed to move task: HTTP ${response.code()}")
@@ -15,7 +15,11 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -52,7 +56,7 @@ fun KeysScreen(
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadKeys(reveal = false) viewModel.loadKeys()
} }
LaunchedEffect(state.toastMessage) { LaunchedEffect(state.toastMessage) {
@@ -77,8 +81,11 @@ fun KeysScreen(
}, },
title = { Text("Keys & Credentials") }, title = { Text("Keys & Credentials") },
actions = { actions = {
Button(onClick = { viewModel.loadKeys(reveal = !state.isRevealed) }) { IconButton(onClick = { viewModel.loadKeys() }) {
Text(if (state.isRevealed) "Hide Values" else "Reveal Values") Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
} }
}, },
) )
@@ -108,9 +115,20 @@ fun KeysScreen(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.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 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()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
@@ -119,6 +137,14 @@ fun KeysScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace, 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)) Spacer(modifier = Modifier.height(4.dp))
if (isEditing) { if (isEditing) {
OutlinedTextField( OutlinedTextField(
@@ -141,7 +167,6 @@ fun KeysScreen(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { Button(onClick = {
isEditing = false isEditing = false
editedValue = value
}) { }) {
Text("Cancel") Text("Cancel")
} }
@@ -153,13 +178,36 @@ fun KeysScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = value, text = displayValue,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
) )
IconButton(onClick = { isEditing = true }) { Row {
Text("Edit", style = MaterialTheme.typography.bodySmall) 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",
)
}
} }
} }
} }
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.keys
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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 com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -13,8 +16,8 @@ import kotlinx.coroutines.withContext
data class KeysUiState( data class KeysUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val envVars: Map<String, String> = emptyMap(), val envVars: Map<String, EnvVarConfig> = emptyMap(),
val isRevealed: Boolean = false, val revealedValues: Map<String, String> = emptyMap(),
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
) )
@@ -29,18 +32,13 @@ class KeysViewModel : ViewModel() {
try { try {
val response = val response =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (reveal) { ApiClient.hermesApi.getEnvVars()
ApiClient.hermesApi.revealEnvVars()
} else {
ApiClient.hermesApi.getEnvVars()
}
} }
if (response.isSuccessful) { if (response.isSuccessful) {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
envVars = response.body().orEmpty(), envVars = response.body().orEmpty(),
isRevealed = reveal,
) )
} }
} else { } 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( fun updateKey(
key: String, key: String,
value: String, value: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val updatedVars =
_uiState.value.envVars.toMutableMap().apply {
put(key, value)
}
val response = val response =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.updateEnvVar(updatedVars) ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key, value))
} }
if (response.isSuccessful) { if (response.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Key updated successfully") } _uiState.update { it.copy(toastMessage = "Key updated successfully") }
loadKeys(_uiState.value.isRevealed) loadKeys()
} else { } else {
_uiState.update { it.copy(toastMessage = "Failed to update key: HTTP ${response.code()}") } _uiState.update { it.copy(toastMessage = "Failed to update key: HTTP ${response.code()}") }
} }
@@ -66,7 +66,11 @@ class PluginsViewModel : ViewModel() {
try { try {
val response = val response =
withContext(Dispatchers.IO) { 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) { if (!response.isSuccessful) {
revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: HTTP ${response.code()}") revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: HTTP ${response.code()}")
@@ -82,7 +86,10 @@ class PluginsViewModel : ViewModel() {
try { try {
val response = val response =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.installPlugin(name) ApiClient.hermesApi.installPlugin(
com.m57.hermescontrol.data.model
.AgentPluginInstallBody(name),
)
} }
if (response.isSuccessful) { if (response.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Plugin installed successfully") } _uiState.update { it.copy(toastMessage = "Plugin installed successfully") }
@@ -149,50 +149,37 @@ fun SystemScreen(
} }
} }
// Doctor reports // Doctor status
state.doctorReport?.let { report -> state.doctorReport?.let { report ->
item { 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()) { Card(modifier = Modifier.fillMaxWidth()) {
Row( Column(modifier = Modifier.padding(16.dp)) {
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))
Text( Text(
text = diagnosis.status.uppercase(), text = "Doctor Diagnostics",
color = style = MaterialTheme.typography.titleLarge,
if (diagnosis.status.lowercase() == "ok" ||
diagnosis.status.lowercase() == "success"
) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
fontWeight = FontWeight.Bold, 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,
)
}
} }
} }
} }
@@ -3,20 +3,21 @@ package com.m57.hermescontrol
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.data.local.AuthManager 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.CronJob
import com.m57.hermescontrol.data.model.DoctorReport
import com.m57.hermescontrol.data.model.DoctorResponse 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.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.KanbanTask
import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.model.MessagingPlatform 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.PluginInfo
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse 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.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.HermesApiService
@@ -795,8 +796,7 @@ class E2eIntegrationTest {
runTest { runTest {
val plugin = PluginInfo("plugin-1", "Desc", "1.0", false, true) val plugin = PluginInfo("plugin-1", "Desc", "1.0", false, true)
coEvery { mockApiService.getPlugins() } returns Response.success(listOf(plugin)) coEvery { mockApiService.getPlugins() } returns Response.success(listOf(plugin))
coEvery { mockApiService.togglePlugin("plugin-1", TogglePluginRequest(true)) } returns coEvery { mockApiService.enablePlugin("plugin-1") } returns Response.success(Unit)
Response.success(Unit)
val viewModel = PluginsViewModel() val viewModel = PluginsViewModel()
viewModel.loadPlugins() viewModel.loadPlugins()
@@ -825,7 +825,7 @@ class E2eIntegrationTest {
@Test @Test
fun testChannelsConfiguration_success() = fun testChannelsConfiguration_success() =
runTest { 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.getMessagingPlatforms() } returns Response.success(listOf(platform))
coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit) coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit)
@@ -844,14 +844,19 @@ class E2eIntegrationTest {
@Test @Test
fun testKeysManagement_success() = fun testKeysManagement_success() =
runTest { 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) coEvery { mockApiService.updateEnvVar(any()) } returns Response.success(Unit)
val viewModel = KeysViewModel() val viewModel = KeysViewModel()
viewModel.loadKeys(reveal = false) viewModel.loadKeys(reveal = false)
advanceUntilIdle() advanceUntilIdle()
assertEquals("value1", viewModel.uiState.value.envVars["KEY1"]) assertEquals(
"value1",
viewModel.uiState.value.envVars["KEY1"]
?.redactedValue,
)
viewModel.updateKey("KEY1", "newValue") viewModel.updateKey("KEY1", "newValue")
advanceUntilIdle() advanceUntilIdle()
@@ -863,7 +868,7 @@ class E2eIntegrationTest {
fun testSystemDiagnostics_success() = fun testSystemDiagnostics_success() =
runTest { runTest {
val stats = SystemStatsResponse(cpu = mapOf("percent" to 50.0), memory = mapOf("percent" to 60.0)) 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.getSystemStats() } returns Response.success(stats)
coEvery { mockApiService.runDoctor() } returns Response.success(doctor) coEvery { mockApiService.runDoctor() } returns Response.success(doctor)
coEvery { mockApiService.triggerBackup() } returns Response.success(Unit) coEvery { mockApiService.triggerBackup() } returns Response.success(Unit)
@@ -878,9 +883,9 @@ class E2eIntegrationTest {
?.cpuPercent, ?.cpuPercent,
) )
assertEquals( assertEquals(
"ok", true,
viewModel.uiState.value.doctorReport viewModel.uiState.value.doctorReport
?.status, ?.ok,
) )
viewModel.triggerBackup() viewModel.triggerBackup()
@@ -893,10 +898,13 @@ class E2eIntegrationTest {
fun testKanbanBoardAndTasks_success() = fun testKanbanBoardAndTasks_success() =
runTest { runTest {
val board = KanbanBoard("board-1", "Backlog", null) val board = KanbanBoard("board-1", "Backlog", null)
val task = KanbanTask("task-1", "board-1", "Do laundry", null, "todo", null) val task = KanbanTask("task-1", "Do laundry", null, "todo", null)
coEvery { mockApiService.getKanbanBoards() } returns Response.success(listOf(board)) coEvery { mockApiService.getKanbanBoards() } returns
coEvery { mockApiService.getKanbanTasks("board-1") } returns Response.success(listOf(task)) Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
coEvery { mockApiService.moveKanbanTask("task-1", MoveTaskRequest("in_progress")) } returns 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) Response.success(Unit)
val viewModel = KanbanViewModel() val viewModel = KanbanViewModel()