diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt index 624153e9..25a7b94e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt @@ -5,8 +5,14 @@ import com.google.gson.annotations.SerializedName data class MessagingPlatform( val id: String, val name: String, + val description: String? = null, + @SerializedName("docs_url") val docsUrl: String? = null, val enabled: Boolean, val configured: Boolean, + @SerializedName("gateway_running") val gatewayRunning: Boolean = false, + val state: String = "disabled", + @SerializedName("error_code") val errorCode: String? = null, + @SerializedName("error_message") val errorMessage: String? = null, @SerializedName("env_vars") val envVars: List? = null, ) @@ -14,9 +20,19 @@ data class EnvVarField( val key: String, val required: Boolean, @SerializedName("is_set") val isSet: Boolean, - val description: String?, - val prompt: String?, + @SerializedName("redacted_value") val redactedValue: String? = null, + val description: String? = null, + val prompt: String? = null, + val help: String? = null, @SerializedName("is_password") val isPassword: Boolean, + val advanced: Boolean = false, + val url: String? = null, +) + +data class MessagingPlatformResponse( + @SerializedName("env_path") val envPath: String, + @SerializedName("gateway_start_command") val gatewayStartCommand: String, + val platforms: List, ) data class MessagingPlatformUpdate( @@ -25,3 +41,9 @@ data class MessagingPlatformUpdate( @SerializedName("clear_env") val clearEnv: List = emptyList(), val profile: String? = null, ) + +data class MessagingPlatformTestResult( + val ok: Boolean, + val state: String, + val message: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatformResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatformResponse.kt deleted file mode 100644 index 5580ae56..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatformResponse.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.m57.hermescontrol.data.model - -data class MessagingPlatformResponse( - val env_path: String, - val gateway_start_command: String, - val platforms: List, -) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 4b2e8f6e..a4d0fc4e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.MessagingPlatformResponse +import com.m57.hermescontrol.data.model.MessagingPlatformTestResult import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.PairingApproveRequest @@ -260,6 +261,11 @@ interface HermesApiService { @Body config: MessagingPlatformUpdate, ): Response + @POST("api/messaging/platforms/{platform_id}/test") + suspend fun testMessagingPlatform( + @Path("platform_id") platformId: String, + ): Response + @GET("api/env") suspend fun getEnvVars(): Response> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt index 0578827d..0964ce0f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt @@ -8,13 +8,24 @@ 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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,11 +37,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState @@ -42,6 +57,42 @@ import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing +// ── State → badge colour / label ────────────────────────────────────── + +private data class StateStyle(val label: String, val color: Color, val icon: ImageVector) + +@Composable +private fun platformStateStyle(state: String): StateStyle = + when (state) { + "connected" -> StateStyle("Connected", Color(0xFF4CAF50), Icons.Filled.CheckCircle) + "pending_restart" -> StateStyle("Restart to apply", Color(0xFFFFC107), Icons.Filled.Refresh) + "gateway_stopped" -> StateStyle("Gateway stopped", Color(0xFFFF9800), Icons.Filled.RadioButtonChecked) + "startup_failed" -> StateStyle("Start failed", Color(0xFFF44336), Icons.Filled.Warning) + "disconnected" -> StateStyle("Disconnected", Color(0xFFFF9800), Icons.Filled.RadioButtonChecked) + "not_configured" -> StateStyle("Not configured", Color(0xFF9E9E9E), Icons.Filled.RadioButtonChecked) + "disabled" -> StateStyle("Disabled", Color(0xFF9E9E9E), Icons.Filled.RadioButtonChecked) + "fatal" -> StateStyle("Error", Color(0xFFF44336), Icons.Filled.Warning) + else -> StateStyle(state, MaterialTheme.colorScheme.onSurfaceVariant, Icons.Filled.RadioButtonChecked) + } + +@Composable +private fun StateBadge(state: String) { + val style = platformStateStyle(state) + Surface( + shape = RoundedCornerShape(4.dp), + color = style.color.copy(alpha = 0.15f), + ) { + Text( + text = style.label, + color = style.color, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } +} + +// ── Main screen ────────────────────────────────────────────────────── + @Composable fun ChannelsScreen( modifier: Modifier = Modifier, @@ -49,7 +100,6 @@ fun ChannelsScreen( viewModel: ChannelsViewModel = viewModel { ChannelsViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - var query by remember { mutableStateOf("") } val filteredPlatforms = @@ -79,7 +129,7 @@ fun ChannelsScreen( ) } - state.errorMessage != null -> { + state.errorMessage != null && state.platforms.isEmpty() -> { ErrorState( message = state.errorMessage ?: "", onRetry = { viewModel.loadPlatforms() }, @@ -96,157 +146,331 @@ fun ChannelsScreen( } else -> { + val gatewayRunning = state.platforms.firstOrNull()?.gatewayRunning ?: false + LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = listContentPadding, verticalArrangement = listItemSpacing, ) { - item { + // ── Restart banner ── + if (state.restartNeeded) { + item(key = "restart_banner") { + RestartBanner( + isRestarting = state.isRestarting, + onRestart = viewModel::restartGateway, + ) + } + } + + // ── Gateway-not-running hint ── + if (!gatewayRunning && !state.restartNeeded) { + item(key = "gateway_offline") { + GatewayOfflineBanner() + } + } + + // ── Search bar ── + item(key = "search") { SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Search channels...", + placeholder = "Search channels…", ) } + + // ── Channel cards ── items(filteredPlatforms, key = { it.id }) { platform -> - var showConfigureForm by remember { mutableStateOf(false) } - - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text(text = platform.name, style = MaterialTheme.typography.titleLarge) - Text( - text = - if (platform.enabled) { - stringResource( - R.string.channels_status_active, - ) - } else { - stringResource(R.string.channels_status_disabled) - }, - color = - if (platform.enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - style = MaterialTheme.typography.bodyMedium, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - checked = platform.enabled, - onCheckedChange = { isChecked -> - viewModel.configurePlatform( - platform.id, - MessagingPlatformUpdate(enabled = isChecked), - ) - }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = { showConfigureForm = !showConfigureForm }) { - Text( - if (showConfigureForm) { - stringResource( - R.string.action_cancel, - ) - } else { - stringResource(R.string.channels_action_configure) - }, - ) - } - } - } - - if (showConfigureForm) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.channels_sec_settings), - style = MaterialTheme.typography.titleMedium, - ) - - val envFields = platform.envVars.orEmpty() - val inputValues = remember { mutableStateMapOf() } - - envFields.forEach { field -> - OutlinedTextField( - value = inputValues[field.key].orEmpty(), - onValueChange = { inputValues[field.key] = it }, - label = { Text(field.prompt ?: field.key) }, - placeholder = { - if (field.isSet) { - Text(stringResource(R.string.channels_placeholder_configured)) - } - }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - } - - Button( - onClick = { - viewModel.configurePlatform( - platform.id, - MessagingPlatformUpdate( - enabled = platform.enabled, - env = inputValues.toMap(), - ), - ) - showConfigureForm = false - }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringResource(R.string.channels_action_save_settings)) - } - } else { - val envFields = platform.envVars.orEmpty() - if (envFields.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.channels_sec_keys), - style = MaterialTheme.typography.titleSmall, - ) - envFields.forEach { field -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = field.key, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = - if (field.isSet) { - stringResource( - R.string.channels_status_configured, - ) - } else { - stringResource(R.string.channels_status_not_set) - }, - style = MaterialTheme.typography.bodyMedium, - color = - if (field.isSet) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - ) - } - } - } - } - } - } + PlatformCard( + platform = platform, + isToggling = state.togglingId == platform.id, + isTesting = state.testingId == platform.id, + onToggle = { enabled -> viewModel.togglePlatform(platform.id, enabled) }, + onTest = { viewModel.testPlatform(platform.id) }, + onSave = { env -> viewModel.configurePlatform(platform.id, env) }, + ) } } } } } } + +// ── Restart banner ─────────────────────────────────────────────────── + +@Composable +private fun RestartBanner( + isRestarting: Boolean, + onRestart: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.channels_restart_banner), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onRestart, + enabled = !isRestarting, + ) { + if (isRestarting) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = stringResource(R.string.channels_action_restart_now), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +// ── Gateway offline banner ─────────────────────────────────────────── + +@Composable +private fun GatewayOfflineBanner() { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.channels_gateway_offline), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Platform card ──────────────────────────────────────────────────── + +@Composable +private fun PlatformCard( + platform: MessagingPlatform, + isToggling: Boolean, + isTesting: Boolean, + onToggle: (Boolean) -> Unit, + onTest: () -> Unit, + onSave: (MessagingPlatformUpdate) -> Unit, +) { + var showConfigureForm by remember { mutableStateOf(false) } + val style = platformStateStyle(platform.state) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + // ── Top row: icon + name + badge + switch ── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = style.icon, + contentDescription = null, + tint = style.color, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = platform.name, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + StateBadge(state = platform.state) + if (platform.id == "telegram") { + // reserved for onboarding badge later + } + } + } + } + + // Toggle switch + if (isToggling) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Switch( + checked = platform.enabled, + onCheckedChange = onToggle, + ) + } + } + + // ── Description ── + if (!platform.description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = platform.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Error message ── + if (!platform.errorMessage.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = platform.errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + // ── Action buttons ── + Spacer(modifier = Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onTest, + enabled = !isTesting, + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = stringResource(R.string.channels_action_test), + style = MaterialTheme.typography.labelSmall, + ) + } + + Button( + onClick = { showConfigureForm = !showConfigureForm }, + ) { + Text( + text = + if (showConfigureForm) { + stringResource(R.string.action_cancel) + } else { + stringResource(R.string.channels_action_configure) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + + // ── Configure form ── + if (showConfigureForm) { + ConfigureForm(platform = platform, onSave = onSave, onClose = { showConfigureForm = false }) + } + } + } +} + +// ── Configure form ─────────────────────────────────────────────────── + +@Composable +private fun ConfigureForm( + platform: MessagingPlatform, + onSave: (MessagingPlatformUpdate) -> Unit, + onClose: () -> Unit, +) { + val envFields = platform.envVars.orEmpty() + val inputValues = remember { mutableStateMapOf() } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.channels_sec_settings), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + envFields.forEach { field -> + OutlinedTextField( + value = inputValues[field.key].orEmpty(), + onValueChange = { inputValues[field.key] = it }, + label = { Text(field.prompt ?: field.key) }, + placeholder = { + if (field.isSet) { + Text(field.redactedValue ?: "******** (Already Configured)") + } + }, + singleLine = true, + visualTransformation = + if (field.isPassword) { + PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onClose, + ) { + Text( + text = stringResource(R.string.action_cancel), + style = MaterialTheme.typography.labelSmall, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + val filledEnv = inputValues.filterValues { it.isNotBlank() } + val update = + MessagingPlatformUpdate( + enabled = platform.enabled, + env = filledEnv, + ) + onSave(update) + onClose() + }, + ) { + Text( + text = stringResource(R.string.channels_action_save_settings), + style = MaterialTheme.typography.labelSmall, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index ee85468b..ee4b3469 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -1,22 +1,29 @@ package com.m57.hermescontrol.ui.channels import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchLoad +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch data class ChannelsUiState( val isLoading: Boolean = false, val platforms: List = emptyList(), val errorMessage: String? = null, val toastMessage: String? = null, + val restartNeeded: Boolean = false, + val isRestarting: Boolean = false, + val togglingId: String? = null, + val testingId: String? = null, ) class ChannelsViewModel : ViewModel(), ToastHost { @@ -26,9 +33,15 @@ class ChannelsViewModel : ViewModel(), ToastHost { fun loadPlatforms() { safeLaunchLoad( apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } }, - onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, + onStart = { + _uiState.update { + it.copy(isLoading = true, errorMessage = null) + } + }, onSuccess = { data -> - _uiState.update { it.copy(isLoading = false, platforms = data?.platforms.orEmpty()) } + _uiState.update { + it.copy(isLoading = false, platforms = data?.platforms.orEmpty()) + } }, onError = { errorMsg -> _uiState.update { @@ -41,18 +54,76 @@ class ChannelsViewModel : ViewModel(), ToastHost { ) } + /** Toggle a platform's enabled state without a full reload. + * On success the local state is updated optimistically and + * restartNeeded is set. */ + fun togglePlatform( + platformId: String, + enabled: Boolean, + ) { + _uiState.update { it.copy(togglingId = platformId) } + safeLaunchLoad( + apiCall = { + safeApiCall { + ApiClient.hermesApi.configurePlatform( + platformId, + MessagingPlatformUpdate(enabled = enabled), + ) + } + }, + onStart = {}, + onSuccess = { + _uiState.update { state -> + state.copy( + platforms = + state.platforms.map { p -> + if (p.id == platformId) { + p.copy( + enabled = enabled, + state = if (enabled) "pending_restart" else "disabled", + ) + } else { + p + } + }, + restartNeeded = true, + togglingId = null, + ) + } + }, + onError = { error -> + _uiState.update { + it.copy( + togglingId = null, + toastMessage = "Toggle failed: $error", + ) + } + }, + ) + } + + /** Configure a platform's env vars and/or settings. Reloads on success. */ fun configurePlatform( platformId: String, update: MessagingPlatformUpdate, ) { safeLaunchLoad( - apiCall = { safeApiCall { ApiClient.hermesApi.configurePlatform(platformId, update) } }, - onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { data -> + apiCall = { + safeApiCall { + ApiClient.hermesApi.configurePlatform(platformId, update) + } + }, + onStart = { + _uiState.update { + it.copy(isLoading = true, errorMessage = null) + } + }, + onSuccess = { val message = "$platformId configured successfully — restart the gateway for changes to take effect" _uiState.update { it.copy( isLoading = false, + restartNeeded = true, toastMessage = message, ) } @@ -62,14 +133,14 @@ class ChannelsViewModel : ViewModel(), ToastHost { _uiState.update { it.copy( isLoading = false, - errorMessage = "Failed to configure platform: $errorMsg", - toastMessage = "Failed to configure platform: $errorMsg", + toastMessage = "Failed to configure: $errorMsg", ) } }, ) } + /** Convenience: configure a platform's env vars via a Map. */ fun configurePlatform( platformId: String, config: Map, @@ -77,6 +148,59 @@ class ChannelsViewModel : ViewModel(), ToastHost { configurePlatform(platformId, MessagingPlatformUpdate(env = config)) } + /** Test connectivity for a platform. */ + fun testPlatform(platformId: String) { + _uiState.update { it.copy(testingId = platformId) } + safeLaunchLoad( + apiCall = { safeApiCall { ApiClient.hermesApi.testMessagingPlatform(platformId) } }, + onStart = {}, + onSuccess = { result -> + val msg = result?.message ?: "Test complete" + _uiState.update { + it.copy(testingId = null, toastMessage = msg) + } + }, + onError = { error -> + _uiState.update { + it.copy(testingId = null, toastMessage = "Test failed: $error") + } + }, + ) + } + + /** Restart the whole gateway. Reloads platforms after a 4s delay. */ + fun restartGateway() { + safeLaunchLoad( + apiCall = { safeApiCall { ApiClient.hermesApi.restartGateway() } }, + onStart = { + _uiState.update { + it.copy(isRestarting = true, errorMessage = null) + } + }, + onSuccess = { + _uiState.update { + it.copy(isRestarting = false, restartNeeded = false) + } + viewModelScope.launch { + delay(4000) + loadPlatforms() + } + }, + onError = { error -> + _uiState.update { + it.copy( + isRestarting = false, + toastMessage = "Restart failed: $error", + ) + } + }, + ) + } + + fun dismissRestartNeeded() { + _uiState.update { it.copy(restartNeeded = false) } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10bcb79c..1bec79a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -375,6 +375,10 @@ Configured Keys: Configured Not set + Test + Restart now + Changes saved. Restart the gateway for them to take effect. + The gateway is not running. Configure channels here, then restart it. Delete diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 7bbe8d93..4255cb85 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -904,12 +904,19 @@ class E2eIntegrationTest { @Test fun testChannelsConfiguration_success() = runTest { - val platform = MessagingPlatform("telegram", "Telegram", false, false, emptyList()) + val platform = + MessagingPlatform( + id = "telegram", + name = "Telegram", + enabled = false, + configured = false, + envVars = emptyList(), + ) coEvery { mockApiService.getMessagingPlatforms() } returns Response.success( MessagingPlatformResponse( - env_path = "/tmp/.env", - gateway_start_command = "hermes gateway start", + envPath = "/tmp/.env", + gatewayStartCommand = "hermes gateway start", platforms = listOf(platform), ), )