20 files changed
+1065
-8
No files matched your search
@@ -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<GatewayMigrationProfile> = emptyList(),
|
||||
@SerialName("multiplex_flag_on") val multiplexFlagOn: Boolean = false,
|
||||
@SerialName("live_served") val liveServed: List<String>? = null,
|
||||
@SerialName("already_multiplexed") val alreadyMultiplexed: Boolean = false,
|
||||
val interrupted: Boolean = false,
|
||||
val blockers: List<String> = emptyList(),
|
||||
val notices: List<String> = 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<GatewayMigrationService> = 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,
|
||||
)
|
||||
@@ -17,6 +17,7 @@ data class MessagingPlatform(
|
||||
@SerialName("env_vars") val envVars: List<EnvVarField>? = 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
|
||||
|
||||
@@ -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<String, PlatformStatus?>? = null,
|
||||
@SerialName("gateway_shared_with")
|
||||
val gatewaySharedWith: List<String>? = null,
|
||||
val memory: MemoryPressureStatus? = null,
|
||||
val disk: DiskPressureStatus? = null,
|
||||
)
|
||||
|
||||
@@ -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<Unit>
|
||||
|
||||
@GET("api/gateway/migrate/plan")
|
||||
suspend fun getGatewayMigrationPlan(): Response<GatewayMigrationPlan>
|
||||
|
||||
@POST("api/gateway/migrate")
|
||||
suspend fun startGatewayMigration(): Response<GatewayMigrationStartResponse>
|
||||
|
||||
@GET("api/profiles")
|
||||
suspend fun getProfiles(): Response<ProfilesResponse>
|
||||
|
||||
@@ -677,7 +686,7 @@ interface HermesApiService : KanbanApiService {
|
||||
suspend fun configurePlatform(
|
||||
@Path("platform_id") platformId: String,
|
||||
@Body config: MessagingPlatformUpdate,
|
||||
): Response<Unit>
|
||||
): Response<MessagingPlatformUpdateResponse>
|
||||
|
||||
@POST("api/messaging/platforms/{platform_id}/test")
|
||||
suspend fun testMessagingPlatform(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user