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 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.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<EnvVarRevealResponse>
@HTTP(method = "DELETE", path = "api/env", hasBody = true)
suspend fun deleteEnvVar(
@Body request: EnvVarDeleteRequest,
): Response<Unit>
@POST("api/ops/backup")
suspend fun triggerBackup(): Response<ActionResponse>
@@ -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))
}
}
}
}
}
@@ -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<String, EnvVarConfig>,
val expanded: Boolean,
)
data class KeysUiState(
val isLoading: Boolean = false,
val envVars: Map<String, EnvVarConfig> = emptyMap(),
val categories: List<CategorySection> = emptyList(),
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 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<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(
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) }
}