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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,20 @@
|
||||
<string name="system_gateway_start">بدء</string>
|
||||
<string name="system_gateway_restart">إعادة التشغيل</string>
|
||||
<string name="system_gateway_stop">إيقاف</string>
|
||||
<string name="system_migration_title">ترحيل البوابة</string>
|
||||
<string name="system_migration_description">راجع خطة الترحيل من الخادم قبل نقل بوابات الملفات الشخصية إلى مُضاعِف مشترك واحد.</string>
|
||||
<string name="system_migration_profiles">الملفات الشخصية</string>
|
||||
<string name="system_migration_automatic_eligibility">الأهلية التلقائية</string>
|
||||
<string name="system_migration_yes">نعم</string>
|
||||
<string name="system_migration_no">لا</string>
|
||||
<string name="system_migration_already_multiplexed">تعمل بالمضاعفة مسبقًا</string>
|
||||
<string name="system_migration_interrupted">تمت مقاطعة عملية ترحيل سابقة. راجع الخطة قبل إعادة المحاولة.</string>
|
||||
<string name="system_migration_blockers">العوائق</string>
|
||||
<string name="system_migration_notices">التنبيهات</string>
|
||||
<string name="system_migration_start">بدء الترحيل</string>
|
||||
<string name="system_migration_confirm_title">بدء ترحيل البوابة؟</string>
|
||||
<string name="system_migration_confirm_description">سيوقف هذا بوابات الملفات الشخصية المستقلة ويعيد تشغيل البوابة الافتراضية كمضاعِف مشترك. ستعمل العملية في الخلفية.</string>
|
||||
<string name="system_migration_progress_title">ترحيل البوابة</string>
|
||||
<string name="memory_active">النشط: %1$s</string>
|
||||
<string name="memory_builtin">الذاكرة المدمجة</string>
|
||||
<string name="memory_builtin_sizes">MEMORY.md: %1$s · USER.md: %2$s</string>
|
||||
@@ -638,6 +652,16 @@
|
||||
<string name="profiles_show_hidden">إظهار الملفات الشخصية المخفية</string>
|
||||
<string name="profiles_hide_hidden">إخفاء الملفات الشخصية المخفية</string>
|
||||
<string name="profiles_hidden_badge">مخفي</string>
|
||||
<string name="profiles_shared_gateway_title">بوابة مشتركة</string>
|
||||
<string name="profiles_shared_gateway_badge">مشترك</string>
|
||||
<plurals name="profiles_shared_gateway_count">
|
||||
<item quantity="zero">لا تُخدم أي ملفات شخصية معًا</item>
|
||||
<item quantity="one">يُخدم %1$d ملف شخصي معًا</item>
|
||||
<item quantity="two">يُخدم ملفان شخصيان معًا</item>
|
||||
<item quantity="few">تُخدم %1$d ملفات شخصية معًا</item>
|
||||
<item quantity="many">تُخدم %1$d ملفًا شخصيًا معًا</item>
|
||||
<item quantity="other">تُخدم %1$d ملفات شخصية معًا</item>
|
||||
</plurals>
|
||||
<string name="profiles_all_hidden_desc">جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية.</string>
|
||||
<string name="profiles_content_desc_active">الملف الشخصي النشط</string>
|
||||
<string name="profiles_title_edit_soul">تعديل ملف الـ Soul: %1$s</string>
|
||||
@@ -934,6 +958,8 @@
|
||||
<string name="channels_action_test">اختبار</string>
|
||||
<string name="channels_action_restart_now">إعادة التشغيل الآن</string>
|
||||
<string name="channels_restart_banner">تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول.</string>
|
||||
<string name="channels_ingress_url">رابط الدخول</string>
|
||||
<string name="channels_copy_ingress_url">نسخ رابط الدخول</string>
|
||||
<string name="pairing_sec_pending">الموافقات المعلقة</string>
|
||||
<string name="pairing_sec_approved">المستخدمون المعتمدون</string>
|
||||
<string name="pairing_action_clear_all">مسح الكل</string>
|
||||
|
||||
@@ -364,6 +364,20 @@
|
||||
<string name="system_action_update_now">지금 업데이트</string>
|
||||
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
||||
<string name="system_update_confirm_desc">Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
|
||||
<string name="system_migration_title">게이트웨이 마이그레이션</string>
|
||||
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
|
||||
<string name="system_migration_profiles">프로필</string>
|
||||
<string name="system_migration_automatic_eligibility">자동 마이그레이션 가능 여부</string>
|
||||
<string name="system_migration_yes">예</string>
|
||||
<string name="system_migration_no">아니요</string>
|
||||
<string name="system_migration_already_multiplexed">이미 멀티플렉싱됨</string>
|
||||
<string name="system_migration_interrupted">이전 마이그레이션이 중단되었습니다. 다시 시도하기 전에 계획을 확인하세요.</string>
|
||||
<string name="system_migration_blockers">차단 요인</string>
|
||||
<string name="system_migration_notices">알림</string>
|
||||
<string name="system_migration_start">마이그레이션 시작</string>
|
||||
<string name="system_migration_confirm_title">게이트웨이 마이그레이션을 시작할까요?</string>
|
||||
<string name="system_migration_confirm_description">독립 프로필 게이트웨이를 중지하고 기본 게이트웨이를 공유 멀티플렉서로 재시작합니다. 작업은 백그라운드에서 실행됩니다.</string>
|
||||
<string name="system_migration_progress_title">게이트웨이 마이그레이션</string>
|
||||
<string name="system_hooks_modal_title">새 셸 훅</string>
|
||||
<string name="system_hooks_field_event">이벤트</string>
|
||||
<string name="system_hooks_field_command">명령어 (절대 경로)</string>
|
||||
@@ -490,6 +504,12 @@
|
||||
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
|
||||
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
|
||||
<string name="profiles_hidden_badge">숨김</string>
|
||||
<string name="profiles_shared_gateway_title">공유 게이트웨이</string>
|
||||
<string name="profiles_shared_gateway_badge">공유</string>
|
||||
<plurals name="profiles_shared_gateway_count">
|
||||
<item quantity="one">프로필 %1$d개가 함께 제공됨</item>
|
||||
<item quantity="other">프로필 %1$d개가 함께 제공됨</item>
|
||||
</plurals>
|
||||
<string name="profiles_all_hidden_desc">모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요.</string>
|
||||
<string name="profiles_content_desc_active">활성 프로필</string>
|
||||
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
|
||||
@@ -749,6 +769,8 @@
|
||||
<string name="channels_action_test">테스트</string>
|
||||
<string name="channels_action_restart_now">지금 재시작</string>
|
||||
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
|
||||
<string name="channels_ingress_url">수신 URL</string>
|
||||
<string name="channels_copy_ingress_url">수신 URL 복사</string>
|
||||
|
||||
<!-- Pairing Screen -->
|
||||
<string name="pairing_sec_pending">승인 대기</string>
|
||||
|
||||
@@ -413,6 +413,20 @@
|
||||
<string name="system_gateway_start">启动</string>
|
||||
<string name="system_gateway_restart">重启</string>
|
||||
<string name="system_gateway_stop">停止</string>
|
||||
<string name="system_migration_title">网关迁移</string>
|
||||
<string name="system_migration_description">将配置文件网关迁移到共享多路复用器前,先查看服务器返回的迁移计划。</string>
|
||||
<string name="system_migration_profiles">配置文件</string>
|
||||
<string name="system_migration_automatic_eligibility">自动迁移资格</string>
|
||||
<string name="system_migration_yes">是</string>
|
||||
<string name="system_migration_no">否</string>
|
||||
<string name="system_migration_already_multiplexed">已使用多路复用</string>
|
||||
<string name="system_migration_interrupted">上一次迁移被中断。重试前请查看计划。</string>
|
||||
<string name="system_migration_blockers">阻碍</string>
|
||||
<string name="system_migration_notices">注意事项</string>
|
||||
<string name="system_migration_start">开始迁移</string>
|
||||
<string name="system_migration_confirm_title">开始网关迁移?</string>
|
||||
<string name="system_migration_confirm_description">这会停止独立的配置文件网关,并将默认网关重启为共享多路复用器。操作将在后台运行。</string>
|
||||
<string name="system_migration_progress_title">网关迁移</string>
|
||||
<string name="memory_active">当前:%1$s</string>
|
||||
<string name="memory_builtin">内置记忆</string>
|
||||
<string name="memory_builtin_sizes">MEMORY.md:%1$s · USER.md:%2$s</string>
|
||||
@@ -625,6 +639,12 @@
|
||||
<string name="profiles_show_hidden">显示隐藏的配置文件</string>
|
||||
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
|
||||
<string name="profiles_hidden_badge">已隐藏</string>
|
||||
<string name="profiles_shared_gateway_title">共享网关</string>
|
||||
<string name="profiles_shared_gateway_badge">共享</string>
|
||||
<plurals name="profiles_shared_gateway_count">
|
||||
<item quantity="one">%1$d 个配置文件共同服务</item>
|
||||
<item quantity="other">%1$d 个配置文件共同服务</item>
|
||||
</plurals>
|
||||
<string name="profiles_all_hidden_desc">所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。</string>
|
||||
<string name="profiles_content_desc_active">当前配置</string>
|
||||
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</string>
|
||||
@@ -921,6 +941,8 @@
|
||||
<string name="channels_action_test">测试</string>
|
||||
<string name="channels_action_restart_now">立即重启</string>
|
||||
<string name="channels_restart_banner">更改已保存。重启网关后生效。</string>
|
||||
<string name="channels_ingress_url">入口 URL</string>
|
||||
<string name="channels_copy_ingress_url">复制入口 URL</string>
|
||||
<string name="pairing_sec_pending">待批准</string>
|
||||
<string name="pairing_sec_approved">已批准用户</string>
|
||||
<string name="pairing_action_clear_all">全部清除</string>
|
||||
|
||||
@@ -487,6 +487,20 @@
|
||||
<string name="system_gateway_start">Start</string>
|
||||
<string name="system_gateway_restart">Restart</string>
|
||||
<string name="system_gateway_stop">Stop</string>
|
||||
<string name="system_migration_title">Gateway migration</string>
|
||||
<string name="system_migration_description">Review the backend migration plan before moving profile gateways onto one shared multiplexer.</string>
|
||||
<string name="system_migration_profiles">Profiles</string>
|
||||
<string name="system_migration_automatic_eligibility">Automatic eligibility</string>
|
||||
<string name="system_migration_yes">Yes</string>
|
||||
<string name="system_migration_no">No</string>
|
||||
<string name="system_migration_already_multiplexed">Already multiplexed</string>
|
||||
<string name="system_migration_interrupted">An earlier migration was interrupted. Review the plan before retrying.</string>
|
||||
<string name="system_migration_blockers">Blockers</string>
|
||||
<string name="system_migration_notices">Notices</string>
|
||||
<string name="system_migration_start">Start migration</string>
|
||||
<string name="system_migration_confirm_title">Start gateway migration?</string>
|
||||
<string name="system_migration_confirm_description">This stops standalone profile gateways and restarts the default gateway as a shared multiplexer. The operation runs in the background.</string>
|
||||
<string name="system_migration_progress_title">Gateway migration</string>
|
||||
|
||||
<!-- Memory section -->
|
||||
<string name="memory_active">Active: %1$s</string>
|
||||
@@ -739,6 +753,12 @@
|
||||
<string name="profiles_show_hidden">Show hidden profiles</string>
|
||||
<string name="profiles_hide_hidden">Hide hidden profiles</string>
|
||||
<string name="profiles_hidden_badge">Hidden</string>
|
||||
<string name="profiles_shared_gateway_title">Shared gateway</string>
|
||||
<string name="profiles_shared_gateway_badge">Shared</string>
|
||||
<plurals name="profiles_shared_gateway_count">
|
||||
<item quantity="one">%1$d profile served together</item>
|
||||
<item quantity="other">%1$d profiles served together</item>
|
||||
</plurals>
|
||||
<string name="profiles_all_hidden_desc">All profiles are currently hidden. Tap below to show hidden profiles.</string>
|
||||
<string name="profiles_content_desc_active">Active Profile</string>
|
||||
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</string>
|
||||
@@ -1077,6 +1097,8 @@
|
||||
<string name="channels_action_test">Test</string>
|
||||
<string name="channels_action_restart_now">Restart now</string>
|
||||
<string name="channels_restart_banner">Changes saved. Restart the gateway for them to take effect.</string>
|
||||
<string name="channels_ingress_url">Ingress URL</string>
|
||||
<string name="channels_copy_ingress_url">Copy ingress URL</string>
|
||||
|
||||
<!-- Pairing Screen -->
|
||||
<string name="pairing_sec_pending">Pending Approvals</string>
|
||||
|
||||
@@ -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()
|
||||
|
||||
+174
@@ -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<StatusResponse>(
|
||||
"""
|
||||
{
|
||||
"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<StatusResponse>("""{"gateway_shared_with": null}""")
|
||||
val oldResponse = json.decodeFromString<StatusResponse>("""{"gateway_running": true}""")
|
||||
|
||||
assertNull(nullResponse.gatewaySharedWith)
|
||||
assertNull(oldResponse.gatewaySharedWith)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `messaging platform decodes backend ingress url`() {
|
||||
val platform =
|
||||
json.decodeFromString<MessagingPlatform>(
|
||||
"""
|
||||
{
|
||||
"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<MessagingPlatformUpdateResponse>(
|
||||
"""{"ok": true, "platform": "telegram", "hot_served": true}""",
|
||||
)
|
||||
val oldResponse =
|
||||
json.decodeFromString<MessagingPlatformUpdateResponse>("""{"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<GatewayMigrationPlan>(
|
||||
"""
|
||||
{
|
||||
"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<GatewayMigrationPlan>(
|
||||
"""
|
||||
{
|
||||
"profiles": [
|
||||
{"profile": "default", "service": null, "services": []}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val missingServices =
|
||||
json.decodeFromString<GatewayMigrationPlan>(
|
||||
"""
|
||||
{
|
||||
"profiles": [
|
||||
{"profile": "default"}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertNull(emptyServices.profiles.first().service)
|
||||
assertTrue(
|
||||
emptyServices.profiles
|
||||
.first()
|
||||
.services
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
missingServices.profiles
|
||||
.first()
|
||||
.services
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Application>(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() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user