feat(#1121): add multiplexed gateway awareness (#1127)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 15:56:32 +03:00
1 parent fe465ad93c
commit 528ec4f28f
20 files changed
+1065 -8

No files matched your search

@@ -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,
)
}
@@ -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))
@@ -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<String>) {
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
// ---------------------------------------------------------------------
@@ -34,6 +34,7 @@ data class ProfilesUiState(
val isLoading: Boolean = false,
val profiles: List<ProfileInfo> = emptyList(),
val activeProfileName: String? = null,
val sharedGatewayProfiles: List<String> = 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<String>?): List<String> =
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"
@@ -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<Pair<String, Int>?>(null) }
@@ -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<GatewayMigrationPlan>): 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
}
}
@@ -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),
)
}
}