* 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:
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) }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user