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 <hermes@agent.local>
This commit is contained in:
M57andHermes Agent authored and GitHub committed 2026-07-03 23:04:06 +03:00
1 parent 5be4075d95
commit e74fe46a5f
6 files changed
+659 -147

No files matched your search

@@ -26,3 +26,8 @@ data class EnvVarUpdate(
val value: String, val value: String,
val profile: String? = null, val profile: String? = null,
) )
data class EnvVarDeleteRequest(
val key: String,
val profile: String? = null,
)
@@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DeleteWebhookResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse
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.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarRevealResponse
import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.EnvVarUpdate
@@ -87,6 +88,7 @@ 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.HTTP
import retrofit2.http.PATCH import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
@@ -482,6 +484,11 @@ interface HermesApiService {
@Body request: EnvVarRevealRequest, @Body request: EnvVarRevealRequest,
): Response<EnvVarRevealResponse> ): Response<EnvVarRevealResponse>
@HTTP(method = "DELETE", path = "api/env", hasBody = true)
suspend fun deleteEnvVar(
@Body request: EnvVarDeleteRequest,
): Response<Unit>
@POST("api/ops/backup") @POST("api/ops/backup")
suspend fun triggerBackup(): Response<ActionResponse> suspend fun triggerBackup(): Response<ActionResponse>
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.ui.keys package com.m57.hermescontrol.ui.keys
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit 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.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -35,6 +43,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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
@@ -45,6 +54,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar 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.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -60,14 +70,28 @@ fun KeysScreen(
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val filteredEnvVars = val filteredCategories =
remember(query, state.envVars) { remember(query, state.categories) {
state.envVars.filter { (key, config) -> if (query.isBlank()) {
key.contains(query, ignoreCase = true) || state.categories
config.description?.contains(query, ignoreCase = true) == true } 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) { LaunchedEffect(Unit) {
viewModel.loadKeys() viewModel.loadKeys()
} }
@@ -81,11 +105,11 @@ fun KeysScreen(
onRefresh = { viewModel.loadKeys() }, onRefresh = { viewModel.loadKeys() },
) { paddingValues -> ) { paddingValues ->
when { when {
state.isLoading && state.envVars.isEmpty() -> { state.isLoading && !hasAnyVars -> {
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState(modifier = Modifier.padding(paddingValues))
} }
state.errorMessage != null -> { state.errorMessage != null && !hasAnyVars -> {
ErrorState( ErrorState(
message = state.errorMessage ?: "", message = state.errorMessage ?: "",
onRetry = { viewModel.loadKeys() }, onRetry = { viewModel.loadKeys() },
@@ -93,7 +117,7 @@ fun KeysScreen(
) )
} }
state.envVars.isEmpty() -> { !hasAnyVars && query.isBlank() -> {
EmptyState( EmptyState(
title = stringResource(R.string.keys_empty_title), title = stringResource(R.string.keys_empty_title),
subtitle = stringResource(R.string.keys_empty_desc), subtitle = stringResource(R.string.keys_empty_desc),
@@ -104,138 +128,80 @@ fun KeysScreen(
} }
else -> { else -> {
Box(Modifier.fillMaxSize()) { LazyColumn(
if (state.isLoading) { modifier = Modifier.fillMaxSize(),
CircularProgressIndicator() contentPadding = listContentPadding,
} else if (state.errorMessage != null) { verticalArrangement = listItemSpacing,
Text( ) {
text = state.errorMessage ?: "", // ── 1. Search ────────────────────────────────────────────
color = MaterialTheme.colorScheme.error, item(key = "search") {
modifier = Modifier.padding(16.dp), SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.keys_search_placeholder),
) )
} else { }
LazyColumn(
modifier = Modifier.fillMaxSize(), // ── 2. Add new key form ─────────────────────────────────
contentPadding = listContentPadding, item(key = "add-key") {
verticalArrangement = listItemSpacing, AddKeySection(
) { newKeyName = state.newKeyName,
item { newKeyValue = state.newKeyValue,
SearchBar( isAdding = state.isAddingKey,
query = query, onNameChange = viewModel::setNewKeyName,
onQueryChange = { query = it }, onValueChange = viewModel::setNewKeyValue,
placeholder = "Search keys...", 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 = // ── 4. Restart banner ────────────────────────────────────
if (isRevealed) { if (state.keysChanged) {
revealedVal.orEmpty() item(key = "restart-banner") {
} else if (config.isSet) { RestartBanner(
config.redactedValue ?: "********" isRestarting = state.isRestartingGateway,
} else { onRestart = viewModel::restartGateway,
stringResource(R.string.keys_label_not_configured) onDismiss = { /* changes stay dirty until restart */ },
} )
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,
),
)
}
}
}
}
}
}
}
} }
} }
} }
@@ -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))
}
}
}
}
}
@@ -3,6 +3,7 @@ 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.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
@@ -18,29 +19,83 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
data class CategorySection(
val name: String,
val vars: Map<String, EnvVarConfig>,
val expanded: Boolean,
)
data class KeysUiState( data class KeysUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val envVars: Map<String, EnvVarConfig> = emptyMap(), val categories: List<CategorySection> = emptyList(),
val revealedValues: Map<String, String> = emptyMap(), val revealedValues: Map<String, String> = emptyMap(),
val newKeyName: String = "",
val newKeyValue: String = "",
val isAddingKey: Boolean = false,
val deletingKeys: Set<String> = emptySet(),
val keysChanged: Boolean = false,
val isRestartingGateway: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
) )
private val CATEGORY_ORDER =
listOf(
"LLM Providers",
"Tool API Keys",
"Messaging Platforms",
"Agent Settings",
)
class KeysViewModel : class KeysViewModel :
ViewModel(), ViewModel(),
ToastHost { ToastHost {
private val _uiState = MutableStateFlow(KeysUiState()) private val _uiState = MutableStateFlow(KeysUiState())
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow() val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow()
fun loadKeys(reveal: Boolean = false) { private fun buildCategoryList(
envVars: Map<String, EnvVarConfig>,
expandedCategories: Set<String> = _uiState.value.categories.map { it.name }.toSet(),
): List<CategorySection> {
// Group by category, uncategorized go to "Other"
val grouped = mutableMapOf<String, MutableMap<String, EnvVarConfig>>()
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( safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } }, apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data -> onSuccess = { data ->
val envVars = data.orEmpty()
val currentCategories = _uiState.value.categories
val expandedCategories = currentCategories.map { it.name to it.expanded }.toMap()
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, 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) { fun revealKey(key: String) {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
@@ -97,7 +246,7 @@ class KeysViewModel :
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Key updated successfully") } _uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") }
loadKeys() 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() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
+9
View File
@@ -705,6 +705,15 @@
<string name="keys_empty_desc">Environment keys from Hermes will appear here.</string> <string name="keys_empty_desc">Environment keys from Hermes will appear here.</string>
<string name="keys_label_not_configured">Not configured</string> <string name="keys_label_not_configured">Not configured</string>
<string name="keys_action_toggle_visibility">Toggle Visibility</string> <string name="keys_action_toggle_visibility">Toggle Visibility</string>
<string name="keys_search_placeholder">Search keys…</string>
<string name="keys_add_header">Add new key</string>
<string name="keys_add_key_label">Key</string>
<string name="keys_add_value_label">Value</string>
<string name="keys_add_action">Add key</string>
<string name="keys_restart_title">Gateway restart required</string>
<string name="keys_restart_desc">Environment changes take effect after a gateway restart.</string>
<string name="keys_restart_action">Restart gateway</string>
<string name="content_desc_delete_key">Delete key</string>
<!-- Channels Screen --> <!-- Channels Screen -->
<string name="channels_empty_title">No channels configured</string> <string name="channels_empty_title">No channels configured</string>
@@ -958,14 +958,14 @@ class E2eIntegrationTest {
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()
advanceUntilIdle() advanceUntilIdle()
assertEquals( val allVars =
"value1", viewModel.uiState.value.categories
viewModel.uiState.value.envVars["KEY1"] .flatMap { it.vars.entries }
?.redactedValue, .associate { it.key to it.value }
) assertEquals("value1", allVars["KEY1"]?.redactedValue)
viewModel.updateKey("KEY1", "newValue") viewModel.updateKey("KEY1", "newValue")
advanceUntilIdle() advanceUntilIdle()