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