Implement MCP Servers, Webhooks, and Models screens
This commit is contained in:
1 parent
ce71aa3e68
commit
43dfbc5470
12 files changed
+1173
-1
No files matched your search
@@ -26,11 +26,14 @@ import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
|||||||
import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent
|
import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent
|
||||||
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
|
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
|
||||||
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
||||||
|
import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
|
||||||
|
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
|
||||||
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
|
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
|
||||||
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
|
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
||||||
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
|
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
|
||||||
import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent
|
import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent
|
||||||
|
import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainNavigation() {
|
fun MainNavigation() {
|
||||||
@@ -48,7 +51,9 @@ fun MainNavigation() {
|
|||||||
currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen ||
|
currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen ||
|
||||||
currentScreen == GatewayScreen || currentScreen == ProfilesScreen ||
|
currentScreen == GatewayScreen || currentScreen == ProfilesScreen ||
|
||||||
currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen ||
|
currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen ||
|
||||||
currentScreen == PairingScreen || currentScreen == ConfigScreen
|
currentScreen == PairingScreen || currentScreen == ConfigScreen ||
|
||||||
|
currentScreen == McpServersScreen || currentScreen == WebhooksScreen ||
|
||||||
|
currentScreen == ModelScreen
|
||||||
val openDrawer = { scope.launch { drawerState.open() } }
|
val openDrawer = { scope.launch { drawerState.open() } }
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
@@ -135,6 +140,33 @@ fun MainNavigation() {
|
|||||||
},
|
},
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("MCP Servers") },
|
||||||
|
selected = currentScreen == McpServersScreen,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
NavigationController.navigateTo(McpServersScreen)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Webhooks") },
|
||||||
|
selected = currentScreen == WebhooksScreen,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
NavigationController.navigateTo(WebhooksScreen)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Models") },
|
||||||
|
selected = currentScreen == ModelScreen,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
NavigationController.navigateTo(ModelScreen)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text("Gateway") },
|
label = { Text("Gateway") },
|
||||||
selected = currentScreen == GatewayScreen,
|
selected = currentScreen == GatewayScreen,
|
||||||
@@ -222,6 +254,24 @@ fun MainNavigation() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry<McpServersScreen> {
|
||||||
|
McpServersScreenContent(
|
||||||
|
onOpenDrawer = { openDrawer() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry<WebhooksScreen> {
|
||||||
|
WebhooksScreenContent(
|
||||||
|
onOpenDrawer = { openDrawer() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry<ModelScreen> {
|
||||||
|
ModelScreenContent(
|
||||||
|
onOpenDrawer = { openDrawer() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
entry<GatewayScreen> {
|
entry<GatewayScreen> {
|
||||||
GatewayScreenContent(
|
GatewayScreenContent(
|
||||||
onOpenDrawer = { openDrawer() },
|
onOpenDrawer = { openDrawer() },
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object PairingScreen : NavKey
|
@Serializable data object PairingScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ConfigScreen : NavKey
|
@Serializable data object ConfigScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object McpServersScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object WebhooksScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object ModelScreen : NavKey
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
data class McpServersResponse(
|
||||||
|
val servers: List<McpServer>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class McpServer(
|
||||||
|
val name: String,
|
||||||
|
val transport: String?,
|
||||||
|
val url: String?,
|
||||||
|
val command: String?,
|
||||||
|
val args: List<String>?,
|
||||||
|
val env: Map<String, String>?,
|
||||||
|
val enabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class McpServerToggleRequest(
|
||||||
|
val enabled: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
data class ModelOptionsResponse(
|
||||||
|
val providers: List<ModelProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ModelProvider(
|
||||||
|
val slug: String,
|
||||||
|
val name: String,
|
||||||
|
val is_current: Boolean?,
|
||||||
|
val is_user_defined: Boolean?,
|
||||||
|
val models: List<String>?,
|
||||||
|
val total_models: Int?,
|
||||||
|
val source: String?,
|
||||||
|
val authenticated: Boolean?,
|
||||||
|
val auth_type: String?,
|
||||||
|
val warning: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
data class WebhooksResponse(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val base_url: String?,
|
||||||
|
val subscriptions: List<WebhookSubscription>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebhookSubscription(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
val events: List<String>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebhooksToggleRequest(
|
||||||
|
val enabled: Boolean,
|
||||||
|
)
|
||||||
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.remote
|
|||||||
import com.m57.hermescontrol.data.model.AchievementsResponse
|
import com.m57.hermescontrol.data.model.AchievementsResponse
|
||||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
|
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||||
import com.m57.hermescontrol.data.model.PairingResponse
|
import com.m57.hermescontrol.data.model.PairingResponse
|
||||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||||
@@ -22,6 +25,8 @@ import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
|||||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
||||||
|
import com.m57.hermescontrol.data.model.WebhooksResponse
|
||||||
|
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
@@ -157,4 +162,34 @@ interface HermesApiService {
|
|||||||
suspend fun updateRawConfig(
|
suspend fun updateRawConfig(
|
||||||
@Body body: UpdateRawConfigRequest,
|
@Body body: UpdateRawConfigRequest,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/mcp/servers")
|
||||||
|
suspend fun getMcpServers(): Response<McpServersResponse>
|
||||||
|
|
||||||
|
@PUT("api/mcp/servers/{name}/enabled")
|
||||||
|
suspend fun toggleMcpServer(
|
||||||
|
@Path("name") name: String,
|
||||||
|
@Body body: McpServerToggleRequest,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/mcp/servers/{name}/test")
|
||||||
|
suspend fun testMcpServer(
|
||||||
|
@Path("name") name: String,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@DELETE("api/mcp/servers/{name}")
|
||||||
|
suspend fun deleteMcpServer(
|
||||||
|
@Path("name") name: String,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/webhooks")
|
||||||
|
suspend fun getWebhooks(): Response<WebhooksResponse>
|
||||||
|
|
||||||
|
@POST("api/webhooks/enable")
|
||||||
|
suspend fun toggleWebhooks(
|
||||||
|
@Body body: WebhooksToggleRequest,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/model/options")
|
||||||
|
suspend fun getModelOptions(): Response<ModelOptionsResponse>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun McpServersScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
|
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.toastMessage) {
|
||||||
|
state.toastMessage?.let { msg ->
|
||||||
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
viewModel.clearToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
if (onOpenDrawer != null) {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Menu,
|
||||||
|
contentDescription = "Open Drawer",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text("MCP Servers") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.loadServers() }) {
|
||||||
|
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
) {
|
||||||
|
if (state.isLoading && state.servers.isEmpty()) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
} else if (state.errorMessage != null && state.servers.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = { viewModel.loadServers() }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(state.servers) { server ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (server.enabled) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = server.name.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Transport: ${server.transport ?: "stdio"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = server.enabled,
|
||||||
|
onCheckedChange = { viewModel.toggleServer(server) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.command != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}"
|
||||||
|
Text(
|
||||||
|
text = "Command:",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = fullCmd,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.testServer(server.name) },
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("Test")
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.deleteServer(server.name) },
|
||||||
|
) {
|
||||||
|
Text("Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.McpServer
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
data class McpServersUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isActionRunning: Boolean = false,
|
||||||
|
val servers: List<McpServer> = emptyList(),
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class McpServersViewModel : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(McpServersUiState())
|
||||||
|
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadServers() {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClient.hermesApi.getMcpServers()
|
||||||
|
}
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
servers = response.body()?.servers.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load MCP servers: HTTP ${response.code()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load MCP servers: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleServer(server: McpServer) {
|
||||||
|
val originalEnabled = server.enabled
|
||||||
|
val targetEnabled = !originalEnabled
|
||||||
|
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
servers =
|
||||||
|
state.servers.map {
|
||||||
|
if (it.name == server.name) it.copy(enabled = targetEnabled) else it
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClient.hermesApi.toggleMcpServer(server.name, McpServerToggleRequest(targetEnabled))
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
revertToggle(server.name, originalEnabled, "Failed to toggle server: HTTP ${response.code()}")
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
toastMessage = "Server '${server.name}' ${if (targetEnabled) "enabled" else "disabled"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun testServer(name: String) {
|
||||||
|
_uiState.update { it.copy(isActionRunning = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClient.hermesApi.testMcpServer(name)
|
||||||
|
}
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isActionRunning = false,
|
||||||
|
toastMessage = "Server '$name' tested successfully",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isActionRunning = false,
|
||||||
|
toastMessage = "Server '$name' test failed: HTTP ${response.code()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isActionRunning = false,
|
||||||
|
toastMessage = "Server '$name' test failed: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteServer(name: String) {
|
||||||
|
_uiState.update { it.copy(isActionRunning = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClient.hermesApi.deleteMcpServer(name)
|
||||||
|
}
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isActionRunning = false,
|
||||||
|
toastMessage = "Server '$name' deleted successfully",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadServers()
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isActionRunning = false,
|
||||||
|
toastMessage = "Failed to delete server: HTTP ${response.code()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isActionRunning = false,
|
||||||
|
toastMessage = "Failed to delete server: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun revertToggle(
|
||||||
|
name: String,
|
||||||
|
originalEnabled: Boolean,
|
||||||
|
errorMsg: String,
|
||||||
|
) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
servers =
|
||||||
|
state.servers.map {
|
||||||
|
if (it.name == name) it.copy(enabled = originalEnabled) else it
|
||||||
|
},
|
||||||
|
toastMessage = errorMsg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.m57.hermescontrol.ui.model
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ModelScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
|
viewModel: ModelViewModel = viewModel { ModelViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadModelOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.toastMessage) {
|
||||||
|
state.toastMessage?.let { msg ->
|
||||||
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
viewModel.clearToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
if (onOpenDrawer != null) {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Menu,
|
||||||
|
contentDescription = "Open Drawer",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text("Model Providers") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.loadModelOptions() }) {
|
||||||
|
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
) {
|
||||||
|
if (state.isLoading && state.providers.isEmpty()) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
} else if (state.errorMessage != null && state.providers.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
IconButton(onClick = { viewModel.loadModelOptions() }) {
|
||||||
|
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(state.providers) { provider ->
|
||||||
|
val isExpanded = expandedProviderSlug == provider.slug
|
||||||
|
val isCurrent = provider.is_current == true
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
expandedProviderSlug = if (isExpanded) null else provider.slug
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (isCurrent) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = provider.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Slug: ${provider.slug}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
Text(
|
||||||
|
text = "CURRENT",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.warning?.let {
|
||||||
|
if (it.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = isExpanded) {
|
||||||
|
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Available Models:",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
val models = provider.models.orEmpty()
|
||||||
|
if (models.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No models reported or provider needs authentication.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
models.forEach { model ->
|
||||||
|
Text(
|
||||||
|
text = "• $model",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.m57.hermescontrol.ui.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
data class ModelUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val providers: List<ModelProvider> = emptyList(),
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ModelViewModel : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(ModelUiState())
|
||||||
|
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadModelOptions() {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClient.hermesApi.getModelOptions()
|
||||||
|
}
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
providers = response.body()?.providers.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load model options: HTTP ${response.code()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load model options: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.m57.hermescontrol.ui.webhooks
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WebhooksScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
|
viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadWebhooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.toastMessage) {
|
||||||
|
state.toastMessage?.let { msg ->
|
||||||
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
viewModel.clearToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
if (onOpenDrawer != null) {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Menu,
|
||||||
|
contentDescription = "Open Drawer",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text("Webhooks Dispatcher") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.loadWebhooks() }) {
|
||||||
|
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
) {
|
||||||
|
if (state.isLoading && state.baseUrl == null) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
} else if (state.errorMessage != null && state.baseUrl == null) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = { viewModel.loadWebhooks() }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Global Status Switch
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (state.enabled) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Global Webhooks Status",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
state.baseUrl?.let {
|
||||||
|
Text(
|
||||||
|
text = "Local Server URL: $it",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = state.enabled,
|
||||||
|
onCheckedChange = { viewModel.toggleWebhooks(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriptions List Header
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Subscriptions",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.subscriptions.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No webhook subscriptions configured.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(state.subscriptions) { sub ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = sub.name.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Target URL:",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = sub.url,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
sub.events?.let { events ->
|
||||||
|
if (events.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Events Subscribed:",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
events.forEach { event ->
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = event,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.m57.hermescontrol.ui.webhooks
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.WebhookSubscription
|
||||||
|
import com.m57.hermescontrol.data.model.WebhooksResponse
|
||||||
|
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
data class WebhooksUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isActionRunning: Boolean = false,
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val baseUrl: String? = null,
|
||||||
|
val subscriptions: List<WebhookSubscription> = emptyList(),
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class WebhooksViewModel : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(WebhooksUiState())
|
||||||
|
val uiState: StateFlow<WebhooksUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadWebhooks() {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClient.hermesApi.getWebhooks()
|
||||||
|
}
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
enabled = body?.enabled ?: false,
|
||||||
|
baseUrl = body?.base_url,
|
||||||
|
subscriptions = body?.subscriptions.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load webhooks: HTTP ${response.code()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load webhooks: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleWebhooks(targetEnabled: Boolean) {
|
||||||
|
val originalEnabled = _uiState.value.enabled
|
||||||
|
_uiState.update { it.copy(enabled = targetEnabled) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClient.hermesApi.toggleWebhooks(WebhooksToggleRequest(targetEnabled))
|
||||||
|
}
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadWebhooks()
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
enabled = originalEnabled,
|
||||||
|
toastMessage = "Failed to toggle webhooks: HTTP ${response.code()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
enabled = originalEnabled,
|
||||||
|
toastMessage = "Failed to toggle webhooks: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user