diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt new file mode 100644 index 00000000..65f327b5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt @@ -0,0 +1,43 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GatewayMigrationPlan( + @SerialName("default_home") val defaultHome: String? = null, + val profiles: List = emptyList(), + @SerialName("multiplex_flag_on") val multiplexFlagOn: Boolean = false, + @SerialName("live_served") val liveServed: List? = null, + @SerialName("already_multiplexed") val alreadyMultiplexed: Boolean = false, + val interrupted: Boolean = false, + val blockers: List = emptyList(), + val notices: List = emptyList(), + val eligible: Boolean = false, + val command: String? = null, +) + +@Serializable +data class GatewayMigrationProfile( + val profile: String? = null, + val home: String? = null, + val pid: Int? = null, + val service: GatewayMigrationService? = null, + val services: List = emptyList(), + @SerialName("run_as_user") val runAsUser: String? = null, + val uid: Int? = null, + @SerialName("runtime_home") val runtimeHome: String? = null, +) + +@Serializable +data class GatewayMigrationService( + val kind: String? = null, + val system: Boolean? = null, +) + +@Serializable +data class GatewayMigrationStartResponse( + val ok: Boolean = false, + val pid: Int? = null, + val name: String? = null, +) 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 465b5451..74e47ac5 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 @@ -17,6 +17,7 @@ data class MessagingPlatform( @SerialName("env_vars") val envVars: List? = null, @SerialName("updated_at") val updatedAt: String? = null, @SerialName("home_channel") val homeChannel: HomeChannelInfo? = null, + @SerialName("ingress_url") val ingressUrl: String? = null, ) @Serializable @@ -64,6 +65,13 @@ data class MessagingPlatformTestResult( val message: String, ) +@Serializable +data class MessagingPlatformUpdateResponse( + val ok: Boolean = false, + val platform: String? = null, + @SerialName("hot_served") val hotServed: Boolean = false, +) + // ── Telegram onboarding ───────────────────────────────────────────────── @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt index 39a108fe..08b8d0c5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt @@ -1,4 +1,5 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -8,6 +9,8 @@ data class StatusResponse( val active_sessions: Int? = null, val auth_required: Boolean? = null, val gateway_platforms: Map? = null, + @SerialName("gateway_shared_with") + val gatewaySharedWith: List? = null, val memory: MemoryPressureStatus? = null, val disk: DiskPressureStatus? = null, ) 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 f6a29028..d8592605 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 @@ -33,6 +33,8 @@ 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 +import com.m57.hermescontrol.data.model.GatewayMigrationPlan +import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse import com.m57.hermescontrol.data.model.HealthStatus import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -65,6 +67,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse 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.MessagingPlatformUpdateResponse import com.m57.hermescontrol.data.model.MoaConfigResponse import com.m57.hermescontrol.data.model.ModelAssignmentRequest import com.m57.hermescontrol.data.model.ModelAssignmentResponse @@ -390,6 +393,12 @@ interface HermesApiService : KanbanApiService { @POST("api/gateway/restart") suspend fun restartGateway(): Response + @GET("api/gateway/migrate/plan") + suspend fun getGatewayMigrationPlan(): Response + + @POST("api/gateway/migrate") + suspend fun startGatewayMigration(): Response + @GET("api/profiles") suspend fun getProfiles(): Response @@ -677,7 +686,7 @@ interface HermesApiService : KanbanApiService { suspend fun configurePlatform( @Path("platform_id") platformId: String, @Body config: MessagingPlatformUpdate, - ): Response + ): Response @POST("api/messaging/platforms/{platform_id}/test") suspend fun testMessagingPlatform( 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 e79c7b2d..3702fbfa 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 @@ -125,7 +125,8 @@ class ChannelsViewModel : } }, onStart = {}, - onSuccess = { + onSuccess = { response -> + val hotServed = response.hotServed _uiState.update { state -> state.copy( platforms = @@ -133,16 +134,28 @@ class ChannelsViewModel : if (p.id == platformId) { p.copy( enabled = enabled, - state = if (enabled) "pending_restart" else "disabled", + state = + when { + !enabled -> "disabled" + hotServed -> "connected" + else -> "pending_restart" + }, ) } else { p } }, - restartNeeded = true, + restartNeeded = !hotServed, togglingId = null, + toastMessage = + if (hotServed) { + "$platformId updated live" + } else { + null + }, ) } + loadPlatforms() }, onError = { error -> _uiState.update { @@ -201,12 +214,18 @@ class ChannelsViewModel : it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { - val message = "$platformId configured successfully — restart the gateway for changes to take effect" + onSuccess = { response -> + val hotServed = response.hotServed + val message = + if (hotServed) { + "$platformId configured and reloaded live" + } else { + "$platformId configured successfully — restart the gateway for changes to take effect" + } _uiState.update { it.copy( isLoading = false, - restartNeeded = true, + restartNeeded = !hotServed, toastMessage = message, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt index 9f2941ec..b03a65ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.channels.components +import android.content.ClipData import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.width 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.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.RadioButtonChecked @@ -34,18 +36,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.channels.ConfigureForm +import kotlinx.coroutines.launch private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL } @@ -110,6 +117,8 @@ internal fun PlatformCard( ) { var showConfigureForm by remember { mutableStateOf(false) } var showRemoveConfirm by remember { mutableStateOf(false) } + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() val style = platformStateStyle(platform.state) Card(modifier = Modifier.fillMaxWidth()) { @@ -166,6 +175,40 @@ internal fun PlatformCard( ) } + platform.ingressUrl?.takeIf { it.isNotBlank() }?.let { ingressUrl -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.channels_ingress_url), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = ingressUrl, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, ingressUrl))) + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.channels_copy_ingress_url), + modifier = Modifier.size(18.dp), + ) + } + } + } + // ── Error message ── if (!platform.errorMessage.isNullOrBlank()) { Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 2b362b23..468888b7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -232,8 +233,15 @@ fun ProfilesScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (state.sharedGatewayProfiles.isNotEmpty()) { + item(key = "shared_gateway_summary") { + SharedGatewaySummary(state.sharedGatewayProfiles) + } + } + items(state.displayProfiles, key = { it.name }) { profile -> val isActive = profile.name == state.activeProfileName + val isProfileShared = profile.name in state.sharedGatewayProfiles Card( modifier = Modifier.fillMaxWidth(), colors = @@ -293,6 +301,29 @@ fun ProfilesScreen( ) } } + if (isProfileShared) { + val sharedGatewayContainer = + MaterialTheme.colorScheme.secondaryContainer + val onSharedGatewayContainer = + MaterialTheme.colorScheme.onSecondaryContainer + val sharedGatewayLabel = + stringResource(R.string.profiles_shared_gateway_badge) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(sharedGatewayContainer) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = sharedGatewayLabel, + color = onSharedGatewayContainer, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + } } val descriptionText = if (!profile.description.isNullOrBlank()) { @@ -971,6 +1002,43 @@ fun ProfilesScreen( } } +@Composable +private fun SharedGatewaySummary(profiles: List) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_shared_gateway_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + pluralStringResource( + R.plurals.profiles_shared_gateway_count, + profiles.size, + profiles.size, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = profiles.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + // --------------------------------------------------------------------- // Profile Builder Wizard // --------------------------------------------------------------------- diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index 26e3cbd3..b48fc8ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -34,6 +34,7 @@ data class ProfilesUiState( val isLoading: Boolean = false, val profiles: List = emptyList(), val activeProfileName: String? = null, + val sharedGatewayProfiles: List = emptyList(), val selectedSoulContent: String? = null, val isLoadingSoul: Boolean = false, val errorMessage: String? = null, @@ -67,6 +68,15 @@ data class ProfilesUiState( } } +private fun normalizeSharedGatewayProfiles(names: List?): List = + names + .orEmpty() + .asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .toList() + class ProfilesViewModel( private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel(), @@ -110,6 +120,7 @@ class ProfilesViewModel( val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } } val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } } + val statusDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getStatus() } } val profilesResult = profilesDeferred.await() val activeResult = activeDeferred.await() @@ -120,9 +131,20 @@ class ProfilesViewModel( isLoading = false, profiles = profilesResult.data.profiles.orEmpty(), activeProfileName = activeResult.data.active, + sharedGatewayProfiles = emptyList(), hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), ) } + + val statusResult = statusDeferred.await() + if (statusResult is NetworkResult.Success) { + _uiState.update { + it.copy( + sharedGatewayProfiles = + normalizeSharedGatewayProfiles(statusResult.data.gatewaySharedWith), + ) + } + } } else { val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success" val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 9b287edb..c4ec9e55 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -133,6 +133,10 @@ fun SystemScreen( controller = resolvedViewModel.actionProgress, title = stringResource(R.string.system_update_progress_title), ) + ActionProgressDialog( + controller = resolvedViewModel.migrationProgress, + title = stringResource(R.string.system_migration_progress_title), + ) // Credential remove confirmation using ConfirmDialog var credToRemove by remember { mutableStateOf?>(null) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 53c373c6..205f4f84 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.model.CredentialPoolProvider import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DoctorResponse +import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.StatusResponse @@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ActionProgressController import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -53,6 +55,8 @@ data class SystemUiState( val updateInfo: UpdateCheckResponse? = null, val status: StatusResponse? = null, val doctorReport: DoctorResponse? = null, + val migrationPlan: GatewayMigrationPlan? = null, + val migrationSupported: Boolean? = null, val activeAction: String? = null, val actionLog: ActionStatusResponse? = null, val backupArchive: String? = null, @@ -88,6 +92,7 @@ data class SystemUiState( class SystemViewModel( application: Application, + private val migrationDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : AndroidViewModel(application), ToastHost { companion object { @@ -114,12 +119,22 @@ class SystemViewModel( onFinished = { status -> onActionFinished(status) }, ) + /** Progress popup for the optional gateway migration action. */ + val migrationProgress = + ActionProgressController( + scope = viewModelScope, + onFinished = { status -> onMigrationFinished(status) }, + ) + private var actionPollingJob: Job? = null private var downloadJob: Job? = null // ── Full parallel data load ──────────────────────────────────────── fun loadAll() { + // Migration support is optional; probe it independently so an older or + // slow backend cannot hold up the required System data. + loadMigrationPlan() viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } @@ -138,6 +153,7 @@ class SystemViewModel( val statsResult = statsDeferred.await() val statusResult = statusDeferred.await() + val portalResult = portalDeferred.await() val curatorResult = curatorDeferred.await() @@ -224,6 +240,66 @@ class SystemViewModel( } } + /** Load optional migration capability state without affecting other System data. */ + fun loadMigrationPlan() { + viewModelScope.launch { + val result = + withContext(migrationDispatcher) { + safeApiCall { ApiClient.hermesApi.getGatewayMigrationPlan() } + } + _uiState.update { + it.copy( + migrationPlan = (result as? NetworkResult.Success)?.data, + migrationSupported = migrationSupport(result), + ) + } + } + } + + /** + * Start the backend migration only after the Gateway section's confirmation. + * The POST starts an asynchronous `gateway-migrate` action; completion is + * owned by [migrationProgress], not by the trigger response. + */ + fun startMigration() { + val plan = _uiState.value.migrationPlan ?: return + if (plan.alreadyMultiplexed) { + _uiState.update { it.copy(toastMessage = "Gateway is already multiplexed") } + return + } + if (plan.blockers.isNotEmpty()) { + _uiState.update { it.copy(toastMessage = "Migration is blocked by the preflight plan") } + return + } + + migrationProgress.open() + viewModelScope.launch { + val result = + withContext(migrationDispatcher) { + safeApiCall { ApiClient.hermesApi.startGatewayMigration() } + } + when (result) { + is NetworkResult.Success -> { + val response = result.data + val actionName = response.name + if (!response.ok) { + migrationProgress.fail("Migration was rejected by the backend") + } else if (actionName != null) { + migrationProgress.markStarted(actionName) + } else { + migrationProgress.fail( + "Migration was accepted but the backend did not report an action name", + ) + } + } + + is NetworkResult.Failure -> { + migrationProgress.fail("Failed to start migration: ${result.error.message}") + } + } + } + } + /** * Fired when [actionProgress] finishes tracking a backend action. For the * `hermes-update` action we additionally fetch the durable update receipt @@ -242,6 +318,12 @@ class SystemViewModel( loadAll() } + private fun onMigrationFinished(status: ActionStatusResponse?) { + if (status?.exit_code == 0) { + loadAll() + } + } + /** * Pull `GET /api/hermes/update/receipt` and render a compact summary block * for the popup. Returns an empty list when the endpoint is unavailable @@ -862,3 +944,15 @@ class SystemViewModel( _uiState.update { it.copy(toastMessage = null) } } } + +private fun migrationSupport(result: NetworkResult): Boolean? = + when (result) { + is NetworkResult.Success -> { + true + } + + is NetworkResult.Failure -> { + val code = (result.error as? NetworkError.Http)?.code + if (code == 404 || code == 405) false else null + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt index 35262e2d..43e16fe0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt @@ -22,12 +22,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.ConfirmDialog import com.m57.hermescontrol.ui.common.InfoRow import com.m57.hermescontrol.ui.common.SectionHeader import com.m57.hermescontrol.ui.common.StatusBadge @@ -183,5 +190,134 @@ fun LazyListScope.gatewaySection( } } } + + state.migrationPlan?.let { plan -> + item { + GatewayMigrationCard( + plan = plan, + onStartMigration = viewModel::startMigration, + ) + } + } + } +} + +@Composable +private fun GatewayMigrationCard( + plan: GatewayMigrationPlan, + onStartMigration: () -> Unit, +) { + var showConfirmation by remember(plan) { mutableStateOf(false) } + val profileNames = + plan.profiles + .mapNotNull { it.profile?.takeIf(String::isNotBlank) } + .ifEmpty { plan.liveServed.orEmpty() } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.system_migration_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.system_migration_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (profileNames.isNotEmpty()) { + InfoRow( + label = stringResource(R.string.system_migration_profiles), + value = profileNames.joinToString(" · "), + ) + } + InfoRow( + label = stringResource(R.string.system_migration_automatic_eligibility), + value = + stringResource( + if (plan.eligible) { + R.string.system_migration_yes + } else { + R.string.system_migration_no + }, + ), + ) + + if (plan.alreadyMultiplexed) { + Spacer(modifier = Modifier.height(8.dp)) + StatusBadge( + text = stringResource(R.string.system_migration_already_multiplexed), + status = StatusBadgeType.SUCCESS, + ) + } + if (plan.interrupted) { + Spacer(modifier = Modifier.height(8.dp)) + StatusBadge( + text = stringResource(R.string.system_migration_interrupted), + status = StatusBadgeType.WARNING, + ) + } + + if (plan.blockers.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.system_migration_blockers), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + ) + plan.blockers.forEach { blocker -> + Text( + text = "• $blocker", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + if (plan.notices.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.system_migration_notices), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + plan.notices.forEach { notice -> + Text( + text = "• $notice", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (!plan.alreadyMultiplexed && plan.blockers.isEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { showConfirmation = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.system_migration_start)) + } + } + } + } + + if (showConfirmation) { + ConfirmDialog( + title = stringResource(R.string.system_migration_confirm_title), + message = stringResource(R.string.system_migration_confirm_description), + onConfirm = { + showConfirmation = false + onStartMigration() + }, + onDismiss = { showConfirmation = false }, + confirmText = stringResource(R.string.system_migration_start), + ) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3b45c33a..57bcce94 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -422,6 +422,20 @@ بدء إعادة التشغيل إيقاف +ترحيل البوابة +راجع خطة الترحيل من الخادم قبل نقل بوابات الملفات الشخصية إلى مُضاعِف مشترك واحد. +الملفات الشخصية +الأهلية التلقائية +نعم +لا +تعمل بالمضاعفة مسبقًا +تمت مقاطعة عملية ترحيل سابقة. راجع الخطة قبل إعادة المحاولة. +العوائق +التنبيهات +بدء الترحيل +بدء ترحيل البوابة؟ +سيوقف هذا بوابات الملفات الشخصية المستقلة ويعيد تشغيل البوابة الافتراضية كمضاعِف مشترك. ستعمل العملية في الخلفية. +ترحيل البوابة النشط: %1$s الذاكرة المدمجة MEMORY.md: %1$s · USER.md: %2$s @@ -638,6 +652,16 @@ إظهار الملفات الشخصية المخفية إخفاء الملفات الشخصية المخفية مخفي +بوابة مشتركة +مشترك + + لا تُخدم أي ملفات شخصية معًا + يُخدم %1$d ملف شخصي معًا + يُخدم ملفان شخصيان معًا + تُخدم %1$d ملفات شخصية معًا + تُخدم %1$d ملفًا شخصيًا معًا + تُخدم %1$d ملفات شخصية معًا + جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية. الملف الشخصي النشط تعديل ملف الـ Soul: %1$s @@ -934,6 +958,8 @@ اختبار إعادة التشغيل الآن تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول. +رابط الدخول +نسخ رابط الدخول الموافقات المعلقة المستخدمون المعتمدون مسح الكل diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2de852ca..2e18e362 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -364,6 +364,20 @@ 지금 업데이트 Hermes 업데이트? Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다. + 게이트웨이 마이그레이션 + 프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요. + 프로필 + 자동 마이그레이션 가능 여부 + 예 + 아니요 + 이미 멀티플렉싱됨 + 이전 마이그레이션이 중단되었습니다. 다시 시도하기 전에 계획을 확인하세요. + 차단 요인 + 알림 + 마이그레이션 시작 + 게이트웨이 마이그레이션을 시작할까요? + 독립 프로필 게이트웨이를 중지하고 기본 게이트웨이를 공유 멀티플렉서로 재시작합니다. 작업은 백그라운드에서 실행됩니다. + 게이트웨이 마이그레이션 새 셸 훅 이벤트 명령어 (절대 경로) @@ -490,6 +504,12 @@ 숨겨진 프로필 표시 숨겨진 프로필 숨기기 숨김 + 공유 게이트웨이 + 공유 + + 프로필 %1$d개가 함께 제공됨 + 프로필 %1$d개가 함께 제공됨 + 모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요. 활성 프로필 소울 프로필 편집: %1$s @@ -749,6 +769,8 @@ 테스트 지금 재시작 변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다. + 수신 URL + 수신 URL 복사 승인 대기 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index fa1c1d3c..4dcf292c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -413,6 +413,20 @@ 启动 重启 停止 + 网关迁移 + 将配置文件网关迁移到共享多路复用器前,先查看服务器返回的迁移计划。 + 配置文件 + 自动迁移资格 + 是 + 否 + 已使用多路复用 + 上一次迁移被中断。重试前请查看计划。 + 阻碍 + 注意事项 + 开始迁移 + 开始网关迁移? + 这会停止独立的配置文件网关,并将默认网关重启为共享多路复用器。操作将在后台运行。 + 网关迁移 当前:%1$s 内置记忆 MEMORY.md:%1$s · USER.md:%2$s @@ -625,6 +639,12 @@ 显示隐藏的配置文件 隐藏已隐藏的配置文件 已隐藏 + 共享网关 + 共享 + + %1$d 个配置文件共同服务 + %1$d 个配置文件共同服务 + 所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。 当前配置 编辑 Soul 配置:%1$s @@ -921,6 +941,8 @@ 测试 立即重启 更改已保存。重启网关后生效。 + 入口 URL + 复制入口 URL 待批准 已批准用户 全部清除 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1b0d0ca..ecea621e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -487,6 +487,20 @@ Start Restart Stop + Gateway migration + Review the backend migration plan before moving profile gateways onto one shared multiplexer. + Profiles + Automatic eligibility + Yes + No + Already multiplexed + An earlier migration was interrupted. Review the plan before retrying. + Blockers + Notices + Start migration + Start gateway migration? + This stops standalone profile gateways and restarts the default gateway as a shared multiplexer. The operation runs in the background. + Gateway migration Active: %1$s @@ -739,6 +753,12 @@ Show hidden profiles Hide hidden profiles Hidden + Shared gateway + Shared + + %1$d profile served together + %1$d profiles served together + All profiles are currently hidden. Tap below to show hidden profiles. Active Profile Edit Soul Profile: %1$s @@ -1077,6 +1097,8 @@ Test Restart now Changes saved. Restart the gateway for them to take effect. + Ingress URL + Copy ingress URL Pending Approvals diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 8590fa48..20d62d60 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -27,6 +27,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.MemoryStats import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformResponse +import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse import com.m57.hermescontrol.data.model.MoaConfigResponse import com.m57.hermescontrol.data.model.MoaModelSlot import com.m57.hermescontrol.data.model.ModelOptionsResponse @@ -1118,7 +1119,8 @@ class E2eIntegrationTest { platforms = listOf(platform), ), ) - coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit) + coEvery { mockApiService.configurePlatform("telegram", any()) } returns + Response.success(MessagingPlatformUpdateResponse(ok = true)) val viewModel = ChannelsViewModel() viewModel.loadPlatforms() diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/GatewayMultiplexSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/GatewayMultiplexSerializationTest.kt new file mode 100644 index 00000000..a023f151 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/GatewayMultiplexSerializationTest.kt @@ -0,0 +1,174 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayMultiplexSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `status decodes shared gateway profile names`() { + val response = + json.decodeFromString( + """ + { + "gateway_running": true, + "gateway_shared_with": ["default", "coding"] + } + """.trimIndent(), + ) + + assertEquals(listOf("default", "coding"), response.gatewaySharedWith) + } + + @Test + fun `status accepts null and absent shared gateway field`() { + val nullResponse = json.decodeFromString("""{"gateway_shared_with": null}""") + val oldResponse = json.decodeFromString("""{"gateway_running": true}""") + + assertNull(nullResponse.gatewaySharedWith) + assertNull(oldResponse.gatewaySharedWith) + } + + @Test + fun `messaging platform decodes backend ingress url`() { + val platform = + json.decodeFromString( + """ + { + "id": "telegram", + "name": "Telegram", + "enabled": true, + "configured": true, + "ingress_url": "/some/backend/value" + } + """.trimIndent(), + ) + + assertEquals("/some/backend/value", platform.ingressUrl) + } + + @Test + fun `messaging platform update decodes hot served and old response safely`() { + val hotServed = + json.decodeFromString( + """{"ok": true, "platform": "telegram", "hot_served": true}""", + ) + val oldResponse = + json.decodeFromString("""{"ok": true, "platform": "telegram"}""") + + assertTrue(hotServed.ok) + assertEquals("telegram", hotServed.platform) + assertTrue(hotServed.hotServed) + assertFalse(oldResponse.hotServed) + } + + @Test + fun `migration plan decodes operational states and ignores low level fields in ui`() { + val plan = + json.decodeFromString( + """ + { + "default_home": "/home/hermes", + "profiles": [ + { + "profile": "default", + "home": "/home/hermes", + "pid": 1234, + "service": {"kind": "systemd", "system": false}, + "services": [ + {"kind": "systemd", "system": false}, + {"kind": "systemd", "system": true} + ], + "uid": 1000, + "runtime_home": "/home/hermes" + }, + {"profile": "coding"} + ], + "multiplex_flag_on": false, + "live_served": null, + "already_multiplexed": false, + "interrupted": true, + "blockers": ["duplicate credential"], + "notices": ["callback URL changes"], + "eligible": false, + "command": "hermes gateway migrate --multiplex" + } + """.trimIndent(), + ) + + assertEquals(listOf("default", "coding"), plan.profiles.map { it.profile }) + assertEquals(1234, plan.profiles.first().pid) + assertEquals( + 2, + plan.profiles + .first() + .services.size, + ) + assertEquals( + "systemd", + plan.profiles + .first() + .services[0] + .kind, + ) + assertFalse( + plan.profiles + .first() + .services[0] + .system ?: true, + ) + assertTrue( + plan.profiles + .first() + .services[1] + .system == true, + ) + assertTrue(plan.interrupted) + assertEquals(listOf("duplicate credential"), plan.blockers) + assertEquals(listOf("callback URL changes"), plan.notices) + assertFalse(plan.eligible) + } + + @Test + fun `migration profile service fields remain optional`() { + val emptyServices = + json.decodeFromString( + """ + { + "profiles": [ + {"profile": "default", "service": null, "services": []} + ] + } + """.trimIndent(), + ) + val missingServices = + json.decodeFromString( + """ + { + "profiles": [ + {"profile": "default"} + ] + } + """.trimIndent(), + ) + + assertNull(emptyServices.profiles.first().service) + assertTrue( + emptyServices.profiles + .first() + .services + .isEmpty(), + ) + assertTrue( + missingServices.profiles + .first() + .services + .isEmpty(), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/channels/ChannelsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/channels/ChannelsViewModelTest.kt new file mode 100644 index 00000000..d15d682f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/channels/ChannelsViewModelTest.kt @@ -0,0 +1,109 @@ +package com.m57.hermescontrol.ui.channels + +import com.m57.hermescontrol.data.model.MessagingPlatform +import com.m57.hermescontrol.data.model.MessagingPlatformResponse +import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class ChannelsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockApi: HermesApiService + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + coEvery { mockApi.getMessagingPlatforms() } returns Response.success(platformsResponse()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `hot served update clears restart requirement and refreshes platforms`() { + coEvery { mockApi.configurePlatform("telegram", any()) } returns + Response.success( + MessagingPlatformUpdateResponse( + ok = true, + platform = "telegram", + hotServed = true, + ), + ) + + val viewModel = ChannelsViewModel() + viewModel.loadPlatforms() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.configurePlatform("telegram", MessagingPlatformUpdate(env = mapOf("token" to "redacted"))) + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.restartNeeded) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("reloaded live") == true, + ) + coVerify { mockApi.configurePlatform("telegram", any()) } + coVerify(atLeast = 2) { mockApi.getMessagingPlatforms() } + } + + @Test + fun `update without hot served preserves restart required behavior`() { + coEvery { mockApi.configurePlatform("telegram", any()) } returns + Response.success(MessagingPlatformUpdateResponse(ok = true, platform = "telegram")) + + val viewModel = ChannelsViewModel() + viewModel.loadPlatforms() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.configurePlatform("telegram", MessagingPlatformUpdate(enabled = true)) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.uiState.value.restartNeeded) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("restart the gateway") == true, + ) + } + + private fun platformsResponse(): MessagingPlatformResponse = + MessagingPlatformResponse( + envPath = "/tmp/.env", + gatewayStartCommand = "hermes gateway start", + platforms = + listOf( + MessagingPlatform( + id = "telegram", + name = "Telegram", + enabled = true, + configured = true, + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt index 517a786e..193d87ef 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileSetupCommandResponse import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.RenameProfileRequest +import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.NetworkError @@ -62,6 +63,7 @@ class ProfilesViewModelTest { coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(listOf(ProfileInfo(name = "default", is_default = true)))) coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default")) + coEvery { mockApi.getStatus() } returns Response.success(StatusResponse()) } @Before @@ -360,6 +362,53 @@ class ProfilesViewModelTest { ) } + @Test + fun `loadProfiles displays normalized shared gateway profiles`() { + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + listOf( + ProfileInfo(name = "default", is_default = true), + ProfileInfo(name = "coding"), + ), + ), + ) + coEvery { mockApi.getStatus() } returns + Response.success( + StatusResponse( + gatewaySharedWith = listOf("default", "coding", "coding", " ", ""), + ), + ) + + val vm = createViewModel() + vm.loadProfiles() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("default", "coding"), vm.uiState.value.sharedGatewayProfiles) + assertNull(vm.uiState.value.errorMessage) + } + + @Test + fun `loadProfiles keeps profiles usable when optional status request fails`() { + coEvery { mockApi.getStatus() } returns errorResponse(404) + + val vm = createViewModel() + vm.loadProfiles() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals( + listOf("default"), + vm.uiState.value.profiles + .map { it.name }, + ) + assertEquals("default", vm.uiState.value.activeProfileName) + assertTrue( + vm.uiState.value.sharedGatewayProfiles + .isEmpty(), + ) + assertNull(vm.uiState.value.errorMessage) + } + @Test fun `cloneProfile success calls createProfile with clone_from and reloads`() { coEvery { mockApi.createProfile(any()) } returns Response.success(Unit) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/system/GatewayMigrationViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/system/GatewayMigrationViewModelTest.kt new file mode 100644 index 00000000..ad53c7c7 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/system/GatewayMigrationViewModelTest.kt @@ -0,0 +1,182 @@ +package com.m57.hermescontrol.ui.system + +import android.app.Application +import com.m57.hermescontrol.data.model.GatewayMigrationPlan +import com.m57.hermescontrol.data.model.GatewayMigrationProfile +import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.ui.common.ActionProgressPhase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class GatewayMigrationViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockApp = mockk(relaxed = true) + private lateinit var mockApi: HermesApiService + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `unsupported migration plan is capability local`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns + Response.error(404, "unsupported".toResponseBody(null)) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.migrationSupported == true) + assertEquals(false, viewModel.uiState.value.migrationSupported) + assertNull(viewModel.uiState.value.migrationPlan) + } + + @Test + fun `migration plan exposes backend states without inventing eligibility rules`() { + val plan = + GatewayMigrationPlan( + profiles = listOf(GatewayMigrationProfile(profile = "default")), + eligible = false, + blockers = emptyList(), + notices = listOf("explicit migration is available"), + ) + coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(plan) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(plan, viewModel.uiState.value.migrationPlan) + assertEquals( + false, + viewModel.uiState.value.migrationPlan + ?.eligible, + ) + assertTrue( + viewModel.uiState.value.migrationPlan + ?.blockers + ?.isEmpty() == true, + ) + } + + @Test + fun `migration POST only starts action tracking and does not report completion`() { + val plan = GatewayMigrationPlan(eligible = false) + coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(plan) + coEvery { mockApi.startGatewayMigration() } returns + Response.success( + GatewayMigrationStartResponse( + ok = true, + pid = 1234, + name = "gateway-migrate", + ), + ) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.runCurrent() + + assertTrue(viewModel.migrationProgress.state.value.visible) + assertEquals(ActionProgressPhase.RUNNING, viewModel.migrationProgress.state.value.phase) + assertEquals("gateway-migrate", viewModel.migrationProgress.state.value.actionName) + coVerify { mockApi.startGatewayMigration() } + } + + @Test + fun `migration POST with an unsuccessful body is shown as rejected`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(GatewayMigrationPlan()) + coEvery { mockApi.startGatewayMigration() } returns + Response.success( + GatewayMigrationStartResponse( + ok = false, + name = "gateway-migrate", + ), + ) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(ActionProgressPhase.FAILED, viewModel.migrationProgress.state.value.phase) + assertEquals("Migration was rejected by the backend", viewModel.migrationProgress.state.value.error) + coVerify { mockApi.startGatewayMigration() } + } + + @Test + fun `already multiplexed plan never starts backend action`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns + Response.success(GatewayMigrationPlan(alreadyMultiplexed = true)) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.runCurrent() + + assertFalse(viewModel.migrationProgress.state.value.visible) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("already multiplexed") == true, + ) + coVerify(exactly = 0) { mockApi.startGatewayMigration() } + } + + @Test + fun `migration with blockers never starts backend action`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns + Response.success(GatewayMigrationPlan(blockers = listOf("duplicate credential"))) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.runCurrent() + + assertFalse(viewModel.migrationProgress.state.value.visible) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("blocked") == true, + ) + coVerify(exactly = 0) { mockApi.startGatewayMigration() } + } +}