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("env_vars") val envVars: List<EnvVarField>? = null,
|
||||||
@SerialName("updated_at") val updatedAt: String? = null,
|
@SerialName("updated_at") val updatedAt: String? = null,
|
||||||
@SerialName("home_channel") val homeChannel: HomeChannelInfo? = null,
|
@SerialName("home_channel") val homeChannel: HomeChannelInfo? = null,
|
||||||
|
@SerialName("ingress_url") val ingressUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -64,6 +65,13 @@ data class MessagingPlatformTestResult(
|
|||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessagingPlatformUpdateResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val platform: String? = null,
|
||||||
|
@SerialName("hot_served") val hotServed: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Telegram onboarding ─────────────────────────────────────────────────
|
// ── Telegram onboarding ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -8,6 +9,8 @@ data class StatusResponse(
|
|||||||
val active_sessions: Int? = null,
|
val active_sessions: Int? = null,
|
||||||
val auth_required: Boolean? = null,
|
val auth_required: Boolean? = null,
|
||||||
val gateway_platforms: Map<String, PlatformStatus?>? = null,
|
val gateway_platforms: Map<String, PlatformStatus?>? = null,
|
||||||
|
@SerialName("gateway_shared_with")
|
||||||
|
val gatewaySharedWith: List<String>? = null,
|
||||||
val memory: MemoryPressureStatus? = null,
|
val memory: MemoryPressureStatus? = null,
|
||||||
val disk: DiskPressureStatus? = 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.EnvVarRevealRequest
|
||||||
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
||||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
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.HealthStatus
|
||||||
import com.m57.hermescontrol.data.model.HookResponse
|
import com.m57.hermescontrol.data.model.HookResponse
|
||||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
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.MessagingPlatformResponse
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
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.MoaConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
||||||
@@ -390,6 +393,12 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@POST("api/gateway/restart")
|
@POST("api/gateway/restart")
|
||||||
suspend fun restartGateway(): Response<Unit>
|
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")
|
@GET("api/profiles")
|
||||||
suspend fun getProfiles(): Response<ProfilesResponse>
|
suspend fun getProfiles(): Response<ProfilesResponse>
|
||||||
|
|
||||||
@@ -677,7 +686,7 @@ interface HermesApiService : KanbanApiService {
|
|||||||
suspend fun configurePlatform(
|
suspend fun configurePlatform(
|
||||||
@Path("platform_id") platformId: String,
|
@Path("platform_id") platformId: String,
|
||||||
@Body config: MessagingPlatformUpdate,
|
@Body config: MessagingPlatformUpdate,
|
||||||
): Response<Unit>
|
): Response<MessagingPlatformUpdateResponse>
|
||||||
|
|
||||||
@POST("api/messaging/platforms/{platform_id}/test")
|
@POST("api/messaging/platforms/{platform_id}/test")
|
||||||
suspend fun testMessagingPlatform(
|
suspend fun testMessagingPlatform(
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ class ChannelsViewModel :
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStart = {},
|
onStart = {},
|
||||||
onSuccess = {
|
onSuccess = { response ->
|
||||||
|
val hotServed = response.hotServed
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
platforms =
|
platforms =
|
||||||
@@ -133,16 +134,28 @@ class ChannelsViewModel :
|
|||||||
if (p.id == platformId) {
|
if (p.id == platformId) {
|
||||||
p.copy(
|
p.copy(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
state = if (enabled) "pending_restart" else "disabled",
|
state =
|
||||||
|
when {
|
||||||
|
!enabled -> "disabled"
|
||||||
|
hotServed -> "connected"
|
||||||
|
else -> "pending_restart"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
restartNeeded = true,
|
restartNeeded = !hotServed,
|
||||||
togglingId = null,
|
togglingId = null,
|
||||||
|
toastMessage =
|
||||||
|
if (hotServed) {
|
||||||
|
"$platformId updated live"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
loadPlatforms()
|
||||||
},
|
},
|
||||||
onError = { error ->
|
onError = { error ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -201,12 +214,18 @@ class ChannelsViewModel :
|
|||||||
it.copy(isLoading = true, errorMessage = null)
|
it.copy(isLoading = true, errorMessage = null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess = {
|
onSuccess = { response ->
|
||||||
val message = "$platformId configured successfully — restart the gateway for changes to take effect"
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
restartNeeded = true,
|
restartNeeded = !hotServed,
|
||||||
toastMessage = message,
|
toastMessage = message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.channels.components
|
package com.m57.hermescontrol.ui.channels.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.QrCode
|
import androidx.compose.material.icons.filled.QrCode
|
||||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.channels.ConfigureForm
|
import com.m57.hermescontrol.ui.channels.ConfigureForm
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL }
|
private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL }
|
||||||
|
|
||||||
@@ -110,6 +117,8 @@ internal fun PlatformCard(
|
|||||||
) {
|
) {
|
||||||
var showConfigureForm by remember { mutableStateOf(false) }
|
var showConfigureForm by remember { mutableStateOf(false) }
|
||||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val style = platformStateStyle(platform.state)
|
val style = platformStateStyle(platform.state)
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
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 ──
|
// ── Error message ──
|
||||||
if (!platform.errorMessage.isNullOrBlank()) {
|
if (!platform.errorMessage.isNullOrBlank()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
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.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -232,8 +233,15 @@ fun ProfilesScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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 ->
|
items(state.displayProfiles, key = { it.name }) { profile ->
|
||||||
val isActive = profile.name == state.activeProfileName
|
val isActive = profile.name == state.activeProfileName
|
||||||
|
val isProfileShared = profile.name in state.sharedGatewayProfiles
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
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 =
|
val descriptionText =
|
||||||
if (!profile.description.isNullOrBlank()) {
|
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
|
// Profile Builder Wizard
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -34,6 +34,7 @@ data class ProfilesUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val profiles: List<ProfileInfo> = emptyList(),
|
val profiles: List<ProfileInfo> = emptyList(),
|
||||||
val activeProfileName: String? = null,
|
val activeProfileName: String? = null,
|
||||||
|
val sharedGatewayProfiles: List<String> = emptyList(),
|
||||||
val selectedSoulContent: String? = null,
|
val selectedSoulContent: String? = null,
|
||||||
val isLoadingSoul: Boolean = false,
|
val isLoadingSoul: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
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(
|
class ProfilesViewModel(
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
@@ -110,6 +120,7 @@ class ProfilesViewModel(
|
|||||||
val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
|
val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
|
||||||
val activeDeferred =
|
val activeDeferred =
|
||||||
async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
|
async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
|
||||||
|
val statusDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getStatus() } }
|
||||||
|
|
||||||
val profilesResult = profilesDeferred.await()
|
val profilesResult = profilesDeferred.await()
|
||||||
val activeResult = activeDeferred.await()
|
val activeResult = activeDeferred.await()
|
||||||
@@ -120,9 +131,20 @@ class ProfilesViewModel(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
profiles = profilesResult.data.profiles.orEmpty(),
|
profiles = profilesResult.data.profiles.orEmpty(),
|
||||||
activeProfileName = activeResult.data.active,
|
activeProfileName = activeResult.data.active,
|
||||||
|
sharedGatewayProfiles = emptyList(),
|
||||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val statusResult = statusDeferred.await()
|
||||||
|
if (statusResult is NetworkResult.Success) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
sharedGatewayProfiles =
|
||||||
|
normalizeSharedGatewayProfiles(statusResult.data.gatewaySharedWith),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
||||||
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ fun SystemScreen(
|
|||||||
controller = resolvedViewModel.actionProgress,
|
controller = resolvedViewModel.actionProgress,
|
||||||
title = stringResource(R.string.system_update_progress_title),
|
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
|
// Credential remove confirmation using ConfirmDialog
|
||||||
var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) }
|
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.CuratorResponse
|
||||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
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.HookResponse
|
||||||
import com.m57.hermescontrol.data.model.PortalResponse
|
import com.m57.hermescontrol.data.model.PortalResponse
|
||||||
import com.m57.hermescontrol.data.model.StatusResponse
|
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.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -53,6 +55,8 @@ data class SystemUiState(
|
|||||||
val updateInfo: UpdateCheckResponse? = null,
|
val updateInfo: UpdateCheckResponse? = null,
|
||||||
val status: StatusResponse? = null,
|
val status: StatusResponse? = null,
|
||||||
val doctorReport: DoctorResponse? = null,
|
val doctorReport: DoctorResponse? = null,
|
||||||
|
val migrationPlan: GatewayMigrationPlan? = null,
|
||||||
|
val migrationSupported: Boolean? = null,
|
||||||
val activeAction: String? = null,
|
val activeAction: String? = null,
|
||||||
val actionLog: ActionStatusResponse? = null,
|
val actionLog: ActionStatusResponse? = null,
|
||||||
val backupArchive: String? = null,
|
val backupArchive: String? = null,
|
||||||
@@ -88,6 +92,7 @@ data class SystemUiState(
|
|||||||
|
|
||||||
class SystemViewModel(
|
class SystemViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
|
private val migrationDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) : AndroidViewModel(application),
|
) : AndroidViewModel(application),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -114,12 +119,22 @@ class SystemViewModel(
|
|||||||
onFinished = { status -> onActionFinished(status) },
|
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 actionPollingJob: Job? = null
|
||||||
private var downloadJob: Job? = null
|
private var downloadJob: Job? = null
|
||||||
|
|
||||||
// ── Full parallel data load ────────────────────────────────────────
|
// ── Full parallel data load ────────────────────────────────────────
|
||||||
|
|
||||||
fun loadAll() {
|
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 {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
|
||||||
@@ -138,6 +153,7 @@ class SystemViewModel(
|
|||||||
|
|
||||||
val statsResult = statsDeferred.await()
|
val statsResult = statsDeferred.await()
|
||||||
val statusResult = statusDeferred.await()
|
val statusResult = statusDeferred.await()
|
||||||
|
|
||||||
val portalResult = portalDeferred.await()
|
val portalResult = portalDeferred.await()
|
||||||
val curatorResult = curatorDeferred.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
|
* Fired when [actionProgress] finishes tracking a backend action. For the
|
||||||
* `hermes-update` action we additionally fetch the durable update receipt
|
* `hermes-update` action we additionally fetch the durable update receipt
|
||||||
@@ -242,6 +318,12 @@ class SystemViewModel(
|
|||||||
loadAll()
|
loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onMigrationFinished(status: ActionStatusResponse?) {
|
||||||
|
if (status?.exit_code == 0) {
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull `GET /api/hermes/update/receipt` and render a compact summary block
|
* Pull `GET /api/hermes/update/receipt` and render a compact summary block
|
||||||
* for the popup. Returns an empty list when the endpoint is unavailable
|
* for the popup. Returns an empty list when the endpoint is unavailable
|
||||||
@@ -862,3 +944,15 @@ class SystemViewModel(
|
|||||||
_uiState.update { it.copy(toastMessage = null) }
|
_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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
|
||||||
import com.m57.hermescontrol.theme.Spacing
|
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.InfoRow
|
||||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
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_start">بدء</string>
|
||||||
<string name="system_gateway_restart">إعادة التشغيل</string>
|
<string name="system_gateway_restart">إعادة التشغيل</string>
|
||||||
<string name="system_gateway_stop">إيقاف</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_active">النشط: %1$s</string>
|
||||||
<string name="memory_builtin">الذاكرة المدمجة</string>
|
<string name="memory_builtin">الذاكرة المدمجة</string>
|
||||||
<string name="memory_builtin_sizes">MEMORY.md: %1$s · USER.md: %2$s</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_show_hidden">إظهار الملفات الشخصية المخفية</string>
|
||||||
<string name="profiles_hide_hidden">إخفاء الملفات الشخصية المخفية</string>
|
<string name="profiles_hide_hidden">إخفاء الملفات الشخصية المخفية</string>
|
||||||
<string name="profiles_hidden_badge">مخفي</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_all_hidden_desc">جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية.</string>
|
||||||
<string name="profiles_content_desc_active">الملف الشخصي النشط</string>
|
<string name="profiles_content_desc_active">الملف الشخصي النشط</string>
|
||||||
<string name="profiles_title_edit_soul">تعديل ملف الـ Soul: %1$s</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_test">اختبار</string>
|
||||||
<string name="channels_action_restart_now">إعادة التشغيل الآن</string>
|
<string name="channels_action_restart_now">إعادة التشغيل الآن</string>
|
||||||
<string name="channels_restart_banner">تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول.</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_pending">الموافقات المعلقة</string>
|
||||||
<string name="pairing_sec_approved">المستخدمون المعتمدون</string>
|
<string name="pairing_sec_approved">المستخدمون المعتمدون</string>
|
||||||
<string name="pairing_action_clear_all">مسح الكل</string>
|
<string name="pairing_action_clear_all">مسح الكل</string>
|
||||||
|
|||||||
@@ -364,6 +364,20 @@
|
|||||||
<string name="system_action_update_now">지금 업데이트</string>
|
<string name="system_action_update_now">지금 업데이트</string>
|
||||||
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
||||||
<string name="system_update_confirm_desc">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_modal_title">새 셸 훅</string>
|
||||||
<string name="system_hooks_field_event">이벤트</string>
|
<string name="system_hooks_field_event">이벤트</string>
|
||||||
<string name="system_hooks_field_command">명령어 (절대 경로)</string>
|
<string name="system_hooks_field_command">명령어 (절대 경로)</string>
|
||||||
@@ -490,6 +504,12 @@
|
|||||||
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
|
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
|
||||||
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
|
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
|
||||||
<string name="profiles_hidden_badge">숨김</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_all_hidden_desc">모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요.</string>
|
||||||
<string name="profiles_content_desc_active">활성 프로필</string>
|
<string name="profiles_content_desc_active">활성 프로필</string>
|
||||||
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
|
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
|
||||||
@@ -749,6 +769,8 @@
|
|||||||
<string name="channels_action_test">테스트</string>
|
<string name="channels_action_test">테스트</string>
|
||||||
<string name="channels_action_restart_now">지금 재시작</string>
|
<string name="channels_action_restart_now">지금 재시작</string>
|
||||||
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
|
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
|
||||||
|
<string name="channels_ingress_url">수신 URL</string>
|
||||||
|
<string name="channels_copy_ingress_url">수신 URL 복사</string>
|
||||||
|
|
||||||
<!-- Pairing Screen -->
|
<!-- Pairing Screen -->
|
||||||
<string name="pairing_sec_pending">승인 대기</string>
|
<string name="pairing_sec_pending">승인 대기</string>
|
||||||
|
|||||||
@@ -413,6 +413,20 @@
|
|||||||
<string name="system_gateway_start">启动</string>
|
<string name="system_gateway_start">启动</string>
|
||||||
<string name="system_gateway_restart">重启</string>
|
<string name="system_gateway_restart">重启</string>
|
||||||
<string name="system_gateway_stop">停止</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_active">当前:%1$s</string>
|
||||||
<string name="memory_builtin">内置记忆</string>
|
<string name="memory_builtin">内置记忆</string>
|
||||||
<string name="memory_builtin_sizes">MEMORY.md:%1$s · USER.md:%2$s</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_show_hidden">显示隐藏的配置文件</string>
|
||||||
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
|
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
|
||||||
<string name="profiles_hidden_badge">已隐藏</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_all_hidden_desc">所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。</string>
|
||||||
<string name="profiles_content_desc_active">当前配置</string>
|
<string name="profiles_content_desc_active">当前配置</string>
|
||||||
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</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_test">测试</string>
|
||||||
<string name="channels_action_restart_now">立即重启</string>
|
<string name="channels_action_restart_now">立即重启</string>
|
||||||
<string name="channels_restart_banner">更改已保存。重启网关后生效。</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_pending">待批准</string>
|
||||||
<string name="pairing_sec_approved">已批准用户</string>
|
<string name="pairing_sec_approved">已批准用户</string>
|
||||||
<string name="pairing_action_clear_all">全部清除</string>
|
<string name="pairing_action_clear_all">全部清除</string>
|
||||||
|
|||||||
@@ -487,6 +487,20 @@
|
|||||||
<string name="system_gateway_start">Start</string>
|
<string name="system_gateway_start">Start</string>
|
||||||
<string name="system_gateway_restart">Restart</string>
|
<string name="system_gateway_restart">Restart</string>
|
||||||
<string name="system_gateway_stop">Stop</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 -->
|
<!-- Memory section -->
|
||||||
<string name="memory_active">Active: %1$s</string>
|
<string name="memory_active">Active: %1$s</string>
|
||||||
@@ -739,6 +753,12 @@
|
|||||||
<string name="profiles_show_hidden">Show hidden profiles</string>
|
<string name="profiles_show_hidden">Show hidden profiles</string>
|
||||||
<string name="profiles_hide_hidden">Hide hidden profiles</string>
|
<string name="profiles_hide_hidden">Hide hidden profiles</string>
|
||||||
<string name="profiles_hidden_badge">Hidden</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_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_content_desc_active">Active Profile</string>
|
||||||
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</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_test">Test</string>
|
||||||
<string name="channels_action_restart_now">Restart now</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_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 -->
|
<!-- Pairing Screen -->
|
||||||
<string name="pairing_sec_pending">Pending Approvals</string>
|
<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.MemoryStats
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
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.MoaConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.MoaModelSlot
|
import com.m57.hermescontrol.data.model.MoaModelSlot
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
@@ -1118,7 +1119,8 @@ class E2eIntegrationTest {
|
|||||||
platforms = listOf(platform),
|
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()
|
val viewModel = ChannelsViewModel()
|
||||||
viewModel.loadPlatforms()
|
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.ProfileSetupCommandResponse
|
||||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||||
import com.m57.hermescontrol.data.model.RenameProfileRequest
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
import com.m57.hermescontrol.data.remote.NetworkError
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
@@ -62,6 +63,7 @@ class ProfilesViewModelTest {
|
|||||||
coEvery { mockApi.getProfiles() } returns
|
coEvery { mockApi.getProfiles() } returns
|
||||||
Response.success(ProfilesResponse(listOf(ProfileInfo(name = "default", is_default = true))))
|
Response.success(ProfilesResponse(listOf(ProfileInfo(name = "default", is_default = true))))
|
||||||
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default"))
|
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default"))
|
||||||
|
coEvery { mockApi.getStatus() } returns Response.success(StatusResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@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
|
@Test
|
||||||
fun `cloneProfile success calls createProfile with clone_from and reloads`() {
|
fun `cloneProfile success calls createProfile with clone_from and reloads`() {
|
||||||
coEvery { mockApi.createProfile(any()) } returns Response.success(Unit)
|
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