From e74fe46a5f75166a264915ac321af3cf6c7640ca Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:04:06 +0300 Subject: [PATCH] feat(#442): categorized env keys screen with add/remove, search, and gateway restart (#464) * feat(#442): categorized env keys screen with add/remove, search, and gateway restart - Group env vars by category (LLM Providers, Tool API Keys, Messaging Platforms, Agent Settings, Other) with collapsible AnimatedVisibility sections matching MCP screen pattern - Add new env var form (PUT /api/env) with key/value inputs - Delete env var via DELETE /api/env with EnvVarDeleteRequest body - Reveal/hide/edit inline per-card controls - Search bar filtering across key names and descriptions - Gateway restart banner with one-click action when changes are dirty - Track keysChanged flag showing restart prompt after mutations - Wire delete endpoint in HermesApiService using @HTTP(hasBody=true) - Add EnvVarDeleteRequest data model class * fix(#442): update test to match new categorized KeysViewModel API * fix(#442): satisfy ktlint multiline-expression-wrapping rule --------- Co-authored-by: Hermes Agent --- .../hermescontrol/data/model/EnvVarConfig.kt | 5 + .../data/remote/HermesApiService.kt | 7 + .../m57/hermescontrol/ui/keys/KeysScreen.kt | 586 ++++++++++++++---- .../hermescontrol/ui/keys/KeysViewModel.kt | 187 +++++- app/src/main/res/values/strings.xml | 9 + .../m57/hermescontrol/E2eIntegrationTest.kt | 12 +- 6 files changed, 659 insertions(+), 147 deletions(-) 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 index 0c02532b..293be6c6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/EnvVarConfig.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/EnvVarConfig.kt @@ -26,3 +26,8 @@ data class EnvVarUpdate( val value: String, val profile: String? = null, ) + +data class EnvVarDeleteRequest( + val key: String, + val profile: String? = null, +) 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 ebaf3f7f..40395bfb 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 @@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.EnvVarConfig +import com.m57.hermescontrol.data.model.EnvVarDeleteRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate @@ -87,6 +88,7 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT @@ -482,6 +484,11 @@ interface HermesApiService { @Body request: EnvVarRevealRequest, ): Response + @HTTP(method = "DELETE", path = "api/env", hasBody = true) + suspend fun deleteEnvVar( + @Body request: EnvVarDeleteRequest, + ): Response + @POST("api/ops/backup") suspend fun triggerBackup(): Response 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 c0ce4dec..79a9d3e8 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 @@ -1,7 +1,6 @@ package com.m57.hermescontrol.ui.keys import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,20 +8,29 @@ 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.Add +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -45,6 +54,7 @@ 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.SearchBar +import com.m57.hermescontrol.ui.common.SectionHeader import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing @@ -60,14 +70,28 @@ fun KeysScreen( var query by remember { mutableStateOf("") } - val filteredEnvVars = - remember(query, state.envVars) { - state.envVars.filter { (key, config) -> - key.contains(query, ignoreCase = true) || - config.description?.contains(query, ignoreCase = true) == true + val filteredCategories = + remember(query, state.categories) { + if (query.isBlank()) { + state.categories + } else { + state.categories.mapNotNull { section -> + val filtered = + section.vars.filter { (key, config) -> + key.contains(query, ignoreCase = true) || + config.description?.contains(query, ignoreCase = true) == true + } + if (filtered.isNotEmpty()) { + section.copy(vars = filtered) + } else { + null + } + } } } + val hasAnyVars = state.categories.any { it.vars.isNotEmpty() } + LaunchedEffect(Unit) { viewModel.loadKeys() } @@ -81,11 +105,11 @@ fun KeysScreen( onRefresh = { viewModel.loadKeys() }, ) { paddingValues -> when { - state.isLoading && state.envVars.isEmpty() -> { + state.isLoading && !hasAnyVars -> { LoadingState(modifier = Modifier.padding(paddingValues)) } - state.errorMessage != null -> { + state.errorMessage != null && !hasAnyVars -> { ErrorState( message = state.errorMessage ?: "", onRetry = { viewModel.loadKeys() }, @@ -93,7 +117,7 @@ fun KeysScreen( ) } - state.envVars.isEmpty() -> { + !hasAnyVars && query.isBlank() -> { EmptyState( title = stringResource(R.string.keys_empty_title), subtitle = stringResource(R.string.keys_empty_desc), @@ -104,138 +128,80 @@ fun KeysScreen( } else -> { - Box(Modifier.fillMaxSize()) { - if (state.isLoading) { - CircularProgressIndicator() - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(16.dp), + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + // ── 1. Search ──────────────────────────────────────────── + item(key = "search") { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.keys_search_placeholder), ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, - ) { - item { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = "Search keys...", + } + + // ── 2. Add new key form ───────────────────────────────── + item(key = "add-key") { + AddKeySection( + newKeyName = state.newKeyName, + newKeyValue = state.newKeyValue, + isAdding = state.isAddingKey, + onNameChange = viewModel::setNewKeyName, + onValueChange = viewModel::setNewKeyValue, + onAdd = viewModel::addKey, + ) + } + + // ── 3. Categorized sections ────────────────────────────── + if (filteredCategories.isEmpty() && query.isNotBlank()) { + item(key = "no-results") { + EmptyState( + title = "No matching keys", + subtitle = "Try a different search term.", + modifier = Modifier.padding(paddingValues), + ) + } + } + + filteredCategories.forEach { section -> + item(key = "category-header-${section.name}") { + CategoryHeader( + name = section.name, + count = section.vars.size, + expanded = section.expanded, + onToggle = { viewModel.toggleCategory(section.name) }, + ) + } + + if (section.expanded) { + items( + items = section.vars.toList(), + key = { (key, _) -> "key-$key" }, + ) { (key, config) -> + EnvVarCard( + key = key, + config = config, + revealedValue = state.revealedValues[key], + isDeleting = key in state.deletingKeys, + onReveal = { viewModel.revealKey(key) }, + onHide = { viewModel.hideKey(key) }, + onSave = { value -> viewModel.updateKey(key, value) }, + onDelete = { viewModel.deleteKey(key) }, ) } - items(filteredEnvVars.toList(), key = { it.first }) { (key, config) -> - var isEditing by remember { mutableStateOf(false) } - 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 { - stringResource(R.string.keys_label_not_configured) - } - - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = key, - 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( - value = editedValue, - onValueChange = { editedValue = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Button(onClick = { - viewModel.updateKey(key, editedValue) - isEditing = false - }) { - Text(stringResource(R.string.action_save)) - } - Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = { - isEditing = false - }) { - Text(stringResource(R.string.action_cancel)) - } - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = displayValue, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f), - ) - 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 = - stringResource( - R.string.keys_action_toggle_visibility, - ), - ) - } - } - IconButton(onClick = { - editedValue = if (isRevealed) revealedVal.orEmpty() else "" - isEditing = true - }) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = - stringResource( - R.string.content_desc_edit, - ), - ) - } - } - } - } - } - } - } + // ── 4. Restart banner ──────────────────────────────────── + if (state.keysChanged) { + item(key = "restart-banner") { + RestartBanner( + isRestarting = state.isRestartingGateway, + onRestart = viewModel::restartGateway, + onDismiss = { /* changes stay dirty until restart */ }, + ) } } } @@ -243,3 +209,349 @@ fun KeysScreen( } } } + +// ── Add new key form ───────────────────────────────────────────────────────── + +@Composable +private fun AddKeySection( + newKeyName: String, + newKeyValue: String, + isAdding: Boolean, + onNameChange: (String) -> Unit, + onValueChange: (String) -> Unit, + onAdd: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + SectionHeader(title = stringResource(R.string.keys_add_header)) + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + OutlinedTextField( + value = newKeyName, + onValueChange = onNameChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.keys_add_key_label)) }, + placeholder = { Text("MY_API_KEY") }, + enabled = !isAdding, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = newKeyValue, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.keys_add_value_label)) }, + placeholder = { Text("sk-...") }, + enabled = !isAdding, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onAdd, + modifier = Modifier.fillMaxWidth(), + enabled = !isAdding && newKeyName.isNotBlank() && newKeyValue.isNotBlank(), + ) { + if (isAdding) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + } else { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.keys_add_action)) + } + } + } + } +} + +// ── Category header ────────────────────────────────────────────────────────── + +@Composable +private fun CategoryHeader( + name: String, + count: Int, + expanded: Boolean, + onToggle: () -> Unit, +) { + Card( + onClick = onToggle, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$count", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f), + ) + } + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + +// ── Env var card ────────────────────────────────────────────────────────────── + +@Composable +private fun EnvVarCard( + key: String, + config: com.m57.hermescontrol.data.model.EnvVarConfig, + revealedValue: String?, + isDeleting: Boolean, + onReveal: () -> Unit, + onHide: () -> Unit, + onSave: (String) -> Unit, + onDelete: () -> Unit, +) { + var isEditing by remember { mutableStateOf(false) } + val isRevealed = revealedValue != null + var editedValue by remember(config, revealedValue) { mutableStateOf(revealedValue ?: "") } + + val displayValue = + if (isRevealed) { + revealedValue.orEmpty() + } else if (config.isSet) { + config.redactedValue ?: "********" + } else { + stringResource(R.string.keys_label_not_configured) + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + // Key name + delete button row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = key, + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + if (!isEditing) { + IconButton( + onClick = onDelete, + enabled = !isDeleting, + ) { + if (isDeleting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = + stringResource( + R.string.content_desc_delete_key, + ), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + // Description + 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), + ) + } + + // Provider link + if (!config.url.isNullOrBlank()) { + Text( + text = config.url, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Value display / edit + if (isEditing) { + OutlinedTextField( + value = editedValue, + onValueChange = { editedValue = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.keys_add_value_label)) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = { isEditing = false }) { + Text(stringResource(R.string.action_cancel)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = { + onSave(editedValue) + isEditing = false + }) { + Text(stringResource(R.string.action_save)) + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = displayValue, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + Row { + if (config.isSet) { + IconButton(onClick = if (isRevealed) onHide else onReveal) { + Icon( + imageVector = + if (isRevealed) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = + stringResource( + R.string.keys_action_toggle_visibility, + ), + ) + } + } + IconButton(onClick = { + editedValue = if (isRevealed) revealedValue.orEmpty() else "" + isEditing = true + }) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.content_desc_edit), + ) + } + } + } + } + } + } +} + +// ── Restart banner ──────────────────────────────────────────────────────────── + +@Composable +private fun RestartBanner( + isRestarting: Boolean, + onRestart: () -> Unit, + onDismiss: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.keys_restart_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = stringResource(R.string.keys_restart_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onRestart, + enabled = !isRestarting, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + ), + ) { + if (isRestarting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onTertiary, + ) + } else { + Icon( + imageVector = Icons.Filled.RestartAlt, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.keys_restart_action)) + } + } + } + } +} 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 c49c7b18..9cb08201 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 @@ -3,6 +3,7 @@ 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.EnvVarDeleteRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.remote.ApiClient @@ -18,29 +19,83 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +data class CategorySection( + val name: String, + val vars: Map, + val expanded: Boolean, +) + data class KeysUiState( val isLoading: Boolean = false, - val envVars: Map = emptyMap(), + val categories: List = emptyList(), val revealedValues: Map = emptyMap(), + val newKeyName: String = "", + val newKeyValue: String = "", + val isAddingKey: Boolean = false, + val deletingKeys: Set = emptySet(), + val keysChanged: Boolean = false, + val isRestartingGateway: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, ) +private val CATEGORY_ORDER = + listOf( + "LLM Providers", + "Tool API Keys", + "Messaging Platforms", + "Agent Settings", + ) + class KeysViewModel : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(KeysUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadKeys(reveal: Boolean = false) { + private fun buildCategoryList( + envVars: Map, + expandedCategories: Set = _uiState.value.categories.map { it.name }.toSet(), + ): List { + // Group by category, uncategorized go to "Other" + val grouped = mutableMapOf>() + envVars.forEach { (key, config) -> + val category = config.category?.ifBlank { null } ?: "Other" + grouped.getOrPut(category) { mutableMapOf() }[key] = config + } + + // Sort categories: known order first, then alphabetically + val knownCategories = CATEGORY_ORDER.filter { it in grouped } + val otherCategories = (grouped.keys - CATEGORY_ORDER.toSet()).sorted() + + return (knownCategories + otherCategories).map { name -> + val currentExpanded = + if (expandedCategories.isEmpty()) { + // First load — expand all by default + true + } else { + name in expandedCategories + } + CategorySection( + name = name, + vars = grouped[name] ?: emptyMap(), + expanded = currentExpanded, + ) + } + } + + fun loadKeys() { safeLaunchLoad( apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> + val envVars = data.orEmpty() + val currentCategories = _uiState.value.categories + val expandedCategories = currentCategories.map { it.name to it.expanded }.toMap() _uiState.update { it.copy( isLoading = false, - envVars = data.orEmpty(), + categories = buildCategoryList(envVars, expandedCategories.keys), ) } }, @@ -55,6 +110,100 @@ class KeysViewModel : ) } + fun toggleCategory(category: String) { + _uiState.update { state -> + state.copy( + categories = + state.categories.map { section -> + if (section.name == category) { + section.copy(expanded = !section.expanded) + } else { + section + } + }, + ) + } + } + + fun setNewKeyName(name: String) { + _uiState.update { it.copy(newKeyName = name) } + } + + fun setNewKeyValue(value: String) { + _uiState.update { it.copy(newKeyValue = value) } + } + + fun addKey() { + val key = _uiState.value.newKeyName.trim() + val value = _uiState.value.newKeyValue.trim() + if (key.isBlank() || value.isBlank()) { + _uiState.update { it.copy(toastMessage = "Key and value are required") } + return + } + + _uiState.update { it.copy(isAddingKey = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key, value)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isAddingKey = false, + newKeyName = "", + newKeyValue = "", + keysChanged = true, + toastMessage = "Key added successfully", + ) + } + loadKeys() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isAddingKey = false, + toastMessage = "Failed to add key: ${result.error.message}", + ) + } + } + } + } + } + + fun deleteKey(key: String) { + _uiState.update { it.copy(deletingKeys = it.deletingKeys + key) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + deletingKeys = it.deletingKeys - key, + keysChanged = true, + toastMessage = "Key deleted successfully", + ) + } + loadKeys() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + deletingKeys = it.deletingKeys - key, + toastMessage = "Failed to delete key: ${result.error.message}", + ) + } + } + } + } + } + fun revealKey(key: String) { viewModelScope.launch { val result = @@ -97,7 +246,7 @@ class KeysViewModel : } when (result) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Key updated successfully") } + _uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") } loadKeys() } @@ -108,6 +257,36 @@ class KeysViewModel : } } + fun restartGateway() { + _uiState.update { it.copy(isRestartingGateway = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.restartGateway() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isRestartingGateway = false, + keysChanged = false, + toastMessage = "Gateway restart initiated", + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isRestartingGateway = false, + toastMessage = "Failed to restart gateway: ${result.error.message}", + ) + } + } + } + } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f394d3c2..ef0edc63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -705,6 +705,15 @@ Environment keys from Hermes will appear here. Not configured Toggle Visibility + Search keys… + Add new key + Key + Value + Add key + Gateway restart required + Environment changes take effect after a gateway restart. + Restart gateway + Delete key No channels configured diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index b659306c..bfdc0528 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -958,14 +958,14 @@ class E2eIntegrationTest { coEvery { mockApiService.updateEnvVar(any()) } returns Response.success(Unit) val viewModel = KeysViewModel() - viewModel.loadKeys(reveal = false) + viewModel.loadKeys() advanceUntilIdle() - assertEquals( - "value1", - viewModel.uiState.value.envVars["KEY1"] - ?.redactedValue, - ) + val allVars = + viewModel.uiState.value.categories + .flatMap { it.vars.entries } + .associate { it.key to it.value } + assertEquals("value1", allVars["KEY1"]?.redactedValue) viewModel.updateKey("KEY1", "newValue") advanceUntilIdle()