feat: dashboard version local, bottom nav simplificado, services+actions tabs

- Dashboard muestra version del gradle (no API)
- Header sin iconos duplicados (notifications solo en bottom nav)
- Bottom nav: Dashboard, Servers, Alerts (sin Profile ni Logout)
- ServerDetail: tab Services con lista de systemd services
- ServerDetail: tab Actions con Test/Reboot/Shutdown
- Backend: API endpoints para services, reboot, shutdown, test
This commit is contained in:
2026-06-09 18:15:04 -04:00
parent 13f14416a4
commit af3315e6ab
10 changed files with 546 additions and 308 deletions

View File

@@ -220,30 +220,6 @@ fun AppRoot() {
} }
} }
) )
NavigationBarItem(
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Profile") },
selected = currentDestination?.hierarchy?.any { it.route == Screen.Profile.route } == true,
onClick = {
navController.navigate(Screen.Profile.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.Default.Logout, contentDescription = null) },
label = { Text("Logout") },
selected = false,
onClick = {
loginViewModel.logout()
isLoggedIn = false
navController.navigate(Screen.Login.route) {
popUpTo(0) { inclusive = true }
}
}
)
} }
} }
} }

View File

@@ -44,6 +44,18 @@ interface ApiService {
@Body command: CommandRequest @Body command: CommandRequest
): ApiResponse<CommandResult> ): ApiResponse<CommandResult>
@GET("api/servers/{id}/services")
suspend fun getServerServices(@Path("id") id: Int): ApiResponse<List<ServiceInfo>>
@POST("api/servers/{id}/reboot")
suspend fun serverReboot(@Path("id") id: Int): ApiResponse<Map<String, String>>
@POST("api/servers/{id}/shutdown")
suspend fun serverShutdown(@Path("id") id: Int): ApiResponse<Map<String, String>>
@POST("api/servers/{id}/test-connection")
suspend fun testServerConnection(@Path("id") id: Int): ApiResponse<ConnectionResult>
@GET("api/refresh-metrics") @GET("api/refresh-metrics")
suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>> suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>>

View File

@@ -34,3 +34,17 @@ data class LatestMetrics(
val load_average: String? = null, val load_average: String? = null,
val uptime: String? = null val uptime: String? = null
) )
@Serializable
data class ServiceInfo(
val name: String = "",
val load: String = "",
val status: String = "",
val description: String = ""
)
@Serializable
data class ConnectionResult(
val connected: Boolean = false,
val error: String? = null
)

View File

@@ -22,4 +22,20 @@ class ServerRepository {
suspend fun executeCommand(id: Int, command: String): ApiResponse<CommandResult> { suspend fun executeCommand(id: Int, command: String): ApiResponse<CommandResult> {
return api.executeCommand(id, CommandRequest(command)) return api.executeCommand(id, CommandRequest(command))
} }
suspend fun getServerServices(id: Int): ApiResponse<List<ServiceInfo>> {
return api.getServerServices(id)
}
suspend fun rebootServer(id: Int): ApiResponse<Map<String, String>> {
return api.serverReboot(id)
}
suspend fun shutdownServer(id: Int): ApiResponse<Map<String, String>> {
return api.serverShutdown(id)
}
suspend fun testConnection(id: Int): ApiResponse<ConnectionResult> {
return api.testServerConnection(id)
}
} }

View File

@@ -1,6 +1,5 @@
package com.devlab.app.ui.dashboard package com.devlab.app.ui.dashboard
import androidx.compose.animation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -10,6 +9,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -28,6 +28,12 @@ fun DashboardScreen(
viewModel: DashboardViewModel = viewModel() viewModel: DashboardViewModel = viewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val appVersion = remember {
try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.2.0"
} catch (_: Exception) { "1.2.0" }
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -56,22 +62,6 @@ fun DashboardScreen(
tint = MaterialTheme.colorScheme.onPrimary tint = MaterialTheme.colorScheme.onPrimary
) )
} }
IconButton(onClick = { onNotificationsClick() }) {
BadgedBox(badge = {
if (unreadCount > 0) {
Badge(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
) { Text("$unreadCount") }
}
}) {
Icon(
Icons.Default.Notifications,
contentDescription = "Notifications",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
IconButton(onClick = { viewModel.loadStats() }) { IconButton(onClick = { viewModel.loadStats() }) {
Icon( Icon(
Icons.Default.Refresh, Icons.Default.Refresh,
@@ -124,14 +114,12 @@ fun DashboardScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
if (dashboardStats?.application != null) {
Text( Text(
text = "${dashboardStats.application} v${dashboardStats.version ?: ""}", text = "ServerManager v$appVersion",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
}
Text( Text(
text = "Servers", text = "Servers",

View File

@@ -62,52 +62,51 @@ fun ServerDetailScreen(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon( Icon(Icons.Default.ErrorOutline, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Icons.Default.ErrorOutline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(text = state.error ?: "", color = MaterialTheme.colorScheme.error)
text = state.error ?: "",
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = { viewModel.load(serverId) }) { OutlinedButton(onClick = { viewModel.load(serverId) }) { Text("Retry") }
Text("Retry")
}
} }
} }
} }
else -> { else -> {
Column( Column(modifier = Modifier.fillMaxSize().padding(padding)) {
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow( TabRow(
selectedTabIndex = state.selectedTab, selectedTabIndex = state.selectedTab,
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary contentColor = MaterialTheme.colorScheme.primary,
divider = {}
) { ) {
Tab( Tab(
selected = state.selectedTab == 0, selected = state.selectedTab == 0,
onClick = { viewModel.selectTab(0) }, onClick = { viewModel.selectTab(0) },
text = { Text("Info") }, icon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp)) },
icon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp)) } text = { Text("Info") }
) )
Tab( Tab(
selected = state.selectedTab == 1, selected = state.selectedTab == 1,
onClick = { viewModel.selectTab(1) }, onClick = { viewModel.selectTab(1) },
text = { Text("Metrics") }, icon = { Icon(Icons.Default.StackedLineChart, contentDescription = null, modifier = Modifier.size(18.dp)) },
icon = { Icon(Icons.Default.StackedLineChart, contentDescription = null, modifier = Modifier.size(18.dp)) } text = { Text("Metrics") }
) )
Tab( Tab(
selected = state.selectedTab == 2, selected = state.selectedTab == 2,
onClick = { viewModel.selectTab(2) }, onClick = { viewModel.selectTab(2) },
text = { Text("Console") }, icon = { Icon(Icons.Default.Terminal, contentDescription = null, modifier = Modifier.size(18.dp)) },
icon = { Icon(Icons.Default.Terminal, contentDescription = null, modifier = Modifier.size(18.dp)) } text = { Text("Console") }
)
Tab(
selected = state.selectedTab == 3,
onClick = { viewModel.selectTab(3) },
icon = { Icon(Icons.Default.MiscellaneousServices, contentDescription = null, modifier = Modifier.size(18.dp)) },
text = { Text("Services") }
)
Tab(
selected = state.selectedTab == 4,
onClick = { viewModel.selectTab(4) },
icon = { Icon(Icons.Default.Bolt, contentDescription = null, modifier = Modifier.size(18.dp)) },
text = { Text("Actions") }
) )
} }
@@ -115,6 +114,8 @@ fun ServerDetailScreen(
0 -> InfoTab(state) 0 -> InfoTab(state)
1 -> MetricsTab(state) 1 -> MetricsTab(state)
2 -> ConsoleTab(state, viewModel) 2 -> ConsoleTab(state, viewModel)
3 -> ServicesTab(state, viewModel)
4 -> ActionsTab(state, viewModel)
} }
} }
} }
@@ -147,49 +148,19 @@ private fun InfoTab(state: ServerDetailUiState) {
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(imageVector = Icons.Default.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
imageVector = Icons.Default.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(text = server.name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
text = server.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Surface(color = statusColor.copy(alpha = 0.15f), shape = RoundedCornerShape(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Surface( Box(modifier = Modifier.size(8.dp).clip(RoundedCornerShape(4.dp)).background(statusColor))
color = statusColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(statusColor)
)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(text = server.status.replaceFirstChar { it.uppercase() }, color = statusColor, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium)
text = server.status.replaceFirstChar { it.uppercase() },
color = statusColor,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
} }
} }
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
InfoRow("IP Address", server.ip_address) InfoRow("IP Address", server.ip_address)
InfoRow("SSH Port", server.ssh_port.toString()) InfoRow("SSH Port", server.ssh_port.toString())
if (server.group_name != null) InfoRow("Group", server.group_name) if (server.group_name != null) InfoRow("Group", server.group_name)
@@ -208,39 +179,16 @@ private fun InfoTab(state: ServerDetailUiState) {
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(imageVector = Icons.Default.Memory, contentDescription = null, tint = Orange500)
imageVector = Icons.Default.Memory,
contentDescription = null,
tint = Orange500
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(text = "Current Metrics", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
text = "Current Metrics",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Row( StatCard("CPU", "${metrics.cpu_usage?.toInt() ?: 0}%", Orange500, icon = Icons.Default.Memory, modifier = Modifier.weight(1f))
modifier = Modifier.fillMaxWidth(), StatCard("RAM", "${metrics.ram_usage?.toInt() ?: 0}%", Blue700, icon = Icons.Default.Storage, modifier = Modifier.weight(1f))
horizontalArrangement = Arrangement.spacedBy(12.dp) StatCard("Disk", "${metrics.disk_usage?.toInt() ?: 0}%", Green500, icon = Icons.Default.SdStorage, modifier = Modifier.weight(1f))
) {
StatCard(
"CPU", "${metrics.cpu_usage?.toInt() ?: 0}%", Orange500,
icon = Icons.Default.Memory, modifier = Modifier.weight(1f)
)
StatCard(
"RAM", "${metrics.ram_usage?.toInt() ?: 0}%", Blue700,
icon = Icons.Default.Storage, modifier = Modifier.weight(1f)
)
StatCard(
"Disk", "${metrics.disk_usage?.toInt() ?: 0}%", Green500,
icon = Icons.Default.SdStorage, modifier = Modifier.weight(1f)
)
} }
if (metrics.uptime != null) { if (metrics.uptime != null) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -265,24 +213,9 @@ private fun InfoTab(state: ServerDetailUiState) {
@Composable @Composable
private fun InfoRow(label: String, value: String) { private fun InfoRow(label: String, value: String) {
Row( Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
modifier = Modifier Text(text = label, style = MaterialTheme.typography.bodyMedium, color = Grey700, modifier = Modifier.weight(0.4f))
.fillMaxWidth() Text(text = value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(0.6f))
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = Grey700,
modifier = Modifier.weight(0.4f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(0.6f)
)
} }
HorizontalDivider(color = Grey100, thickness = 0.5.dp) HorizontalDivider(color = Grey100, thickness = 0.5.dp)
} }
@@ -296,12 +229,7 @@ private fun MetricsTab(state: ServerDetailUiState) {
return return
} }
Column( Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
@@ -309,74 +237,29 @@ private fun MetricsTab(state: ServerDetailUiState) {
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(imageVector = Icons.Default.StackedLineChart, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
imageVector = Icons.Default.StackedLineChart,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(text = "Last 24 Hours", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
text = "Last 24 Hours",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
MetricsChart( MetricsChart(points = state.metricsHistory.takeLast(24), modifier = Modifier.fillMaxWidth())
points = state.metricsHistory.takeLast(24),
modifier = Modifier.fillMaxWidth()
)
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text(text = "Recent Values", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
text = "Recent Values",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
state.metricsHistory.takeLast(12).reversed().forEach { point -> state.metricsHistory.takeLast(12).reversed().forEach { point ->
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
.fillMaxWidth()
.padding(vertical = 3.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) { ) {
Row( Row(modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier Text(text = point.recorded_at?.substringAfterLast(" ")?.substringBeforeLast(":") ?: "", style = MaterialTheme.typography.labelSmall, color = Grey500, modifier = Modifier.weight(0.25f))
.fillMaxWidth() Text(text = "${point.cpu_usage?.toInt() ?: 0}%", fontWeight = FontWeight.SemiBold, color = Orange500, modifier = Modifier.weight(0.25f))
.padding(12.dp), Text(text = "${point.ram_usage?.toInt() ?: 0}%", fontWeight = FontWeight.SemiBold, color = Blue700, modifier = Modifier.weight(0.25f))
horizontalArrangement = Arrangement.SpaceEvenly, Text(text = "${point.disk_usage?.toInt() ?: 0}%", fontWeight = FontWeight.SemiBold, color = Green500, modifier = Modifier.weight(0.25f))
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = point.recorded_at?.substringAfterLast(" ")?.substringBeforeLast(":") ?: "",
style = MaterialTheme.typography.labelSmall,
color = Grey500,
modifier = Modifier.weight(0.25f)
)
Text(
text = "${point.cpu_usage?.toInt() ?: 0}%",
fontWeight = FontWeight.SemiBold,
color = Orange500,
modifier = Modifier.weight(0.25f)
)
Text(
text = "${point.ram_usage?.toInt() ?: 0}%",
fontWeight = FontWeight.SemiBold,
color = Blue700,
modifier = Modifier.weight(0.25f)
)
Text(
text = "${point.disk_usage?.toInt() ?: 0}%",
fontWeight = FontWeight.SemiBold,
color = Green500,
modifier = Modifier.weight(0.25f)
)
} }
} }
} }
@@ -385,124 +268,197 @@ private fun MetricsTab(state: ServerDetailUiState) {
@Composable @Composable
private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) { private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) {
Column( Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(imageVector = Icons.Default.Terminal, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
imageVector = Icons.Default.Terminal,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(text = "Execute Command", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
text = "Execute Command",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = state.commandText, value = state.commandText, onValueChange = viewModel::updateCommand,
onValueChange = viewModel::updateCommand,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { Text("e.g. uptime, df -h, free -m") }, placeholder = { Text("e.g. uptime, df -h, free -m") },
singleLine = true, singleLine = true, enabled = !state.isExecuting,
enabled = !state.isExecuting, leadingIcon = { Icon(Icons.Default.Terminal, contentDescription = null) }
leadingIcon = {
Icon(Icons.Default.Terminal, contentDescription = null)
}
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.executeCommand() }, modifier = Modifier.fillMaxWidth(), enabled = state.commandText.isNotBlank() && !state.isExecuting) {
Button(
onClick = { viewModel.executeCommand() },
modifier = Modifier.fillMaxWidth(),
enabled = state.commandText.isNotBlank() && !state.isExecuting
) {
if (state.isExecuting) { if (state.isExecuting) {
CircularProgressIndicator( CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else { } else {
Icon( Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text("Execute") Text("Execute")
} }
} }
if (state.commandOutput != null) { if (state.commandOutput != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Card( Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Grey900)) {
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Grey900)
) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Green500, modifier = Modifier.size(16.dp))
Icons.Default.CheckCircle,
contentDescription = null,
tint = Green500,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text("Output", color = Green500, style = MaterialTheme.typography.labelSmall)
text = "Output",
color = Green500,
style = MaterialTheme.typography.labelSmall
)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(text = state.commandOutput ?: "", color = Green500, style = MaterialTheme.typography.bodyMedium, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
text = state.commandOutput ?: "", }
color = Green500, }
style = MaterialTheme.typography.bodyMedium, }
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace if (state.commandError != null) {
) Spacer(modifier = Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Error", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = state.commandError ?: "", color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodyMedium, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
}
}
} }
} }
} }
if (state.commandError != null) { @Composable
private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.MiscellaneousServices, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Running Services", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(12.dp))
when {
state.isLoadingServices -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.servicesError != null -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = state.servicesError ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = { viewModel.loadServices() }) { Text("Retry") }
}
}
}
state.services.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No services found", color = Grey500)
}
}
else -> {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
state.services.forEach { service ->
val isActive = service.status == "active"
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(if (isActive) Green500 else Grey500)
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = service.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
if (service.description.isNotBlank()) {
Text(text = service.description, style = MaterialTheme.typography.labelSmall, color = Grey500)
}
}
Text(
text = if (isActive) "Active" else service.status,
color = if (isActive) Green500 else Grey500,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
}
}
@Composable
private fun ActionsTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Server Actions", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
ActionButton(
icon = Icons.Default.NetworkCheck,
label = "Test Connection",
description = "Check if SSH is reachable",
isLoading = state.isTestingConnection,
color = MaterialTheme.colorScheme.primary,
onClick = { viewModel.testConnection() }
)
ActionButton(
icon = Icons.Default.RestartAlt,
label = "Reboot Server",
description = "Restart the server remotely",
isLoading = state.isActionRunning && state.actionMessage == "Rebooting server...",
color = Orange500,
onClick = { viewModel.reboot() }
)
ActionButton(
icon = Icons.Default.PowerSettingsNew,
label = "Shutdown Server",
description = "Power off the server remotely",
isLoading = state.isActionRunning && state.actionMessage == "Shutting down server...",
color = Red500,
onClick = { viewModel.shutdown() }
)
if (state.actionMessage != null) {
Spacer(modifier = Modifier.height(16.dp))
val isSuccess = state.connectionResult == true || (
state.actionMessage != null &&
!state.actionMessage!!.contains("failed", ignoreCase = true) &&
!state.actionMessage!!.contains("error", ignoreCase = true) &&
state.actionMessage != "Rebooting server..." &&
state.actionMessage != "Shutting down server..."
)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer containerColor = if (isSuccess) Green500.copy(alpha = 0.1f)
else MaterialTheme.colorScheme.errorContainer
) )
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
Icons.Default.Error, imageVector = if (isSuccess) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.error, tint = if (isSuccess) Green500 else MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp) modifier = Modifier.size(20.dp)
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Error", text = state.actionMessage ?: "",
color = MaterialTheme.colorScheme.error, color = if (isSuccess) Green500 else MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.commandError ?: "",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
) )
} }
} }
@@ -510,4 +466,42 @@ private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewMo
} }
} }
@Composable
private fun ActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
isLoading: Boolean,
color: androidx.compose.ui.graphics.Color,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
enabled = !isLoading,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = label, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(text = description, style = MaterialTheme.typography.bodySmall, color = Grey500)
}
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.KeyboardArrowRight, contentDescription = null, tint = Grey500)
}
}
}
}

View File

@@ -19,7 +19,14 @@ data class ServerDetailUiState(
val commandOutput: String? = null, val commandOutput: String? = null,
val commandError: String? = null, val commandError: String? = null,
val isExecuting: Boolean = false, val isExecuting: Boolean = false,
val selectedTab: Int = 0 val selectedTab: Int = 0,
val services: List<ServiceInfo> = emptyList(),
val isLoadingServices: Boolean = false,
val servicesError: String? = null,
val isTestingConnection: Boolean = false,
val connectionResult: Boolean? = null,
val actionMessage: String? = null,
val isActionRunning: Boolean = false
) )
class ServerDetailViewModel : ViewModel() { class ServerDetailViewModel : ViewModel() {
@@ -75,6 +82,96 @@ class ServerDetailViewModel : ViewModel() {
} }
} }
fun loadServices() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoadingServices = true, servicesError = null)
try {
val response = repository.getServerServices(serverId)
if (response.success) {
_uiState.value = _uiState.value.copy(
isLoadingServices = false,
services = response.data ?: emptyList()
)
} else {
_uiState.value = _uiState.value.copy(
isLoadingServices = false,
servicesError = response.error ?: "Failed to load services"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoadingServices = false,
servicesError = e.message ?: "Network error"
)
}
}
}
fun testConnection() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isTestingConnection = true, connectionResult = null, actionMessage = null)
try {
val response = repository.testConnection(serverId)
if (response.success && response.data != null) {
_uiState.value = _uiState.value.copy(
isTestingConnection = false,
connectionResult = response.data.connected,
actionMessage = if (response.data.connected) "Connection successful" else "Connection failed: ${response.data.error ?: "Unknown error"}"
)
} else {
_uiState.value = _uiState.value.copy(
isTestingConnection = false,
connectionResult = false,
actionMessage = response.error ?: "Test failed"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isTestingConnection = false,
connectionResult = false,
actionMessage = e.message ?: "Network error"
)
}
}
}
fun reboot() {
runAction("Rebooting server...") {
repository.rebootServer(serverId)
}
}
fun shutdown() {
runAction("Shutting down server...") {
repository.shutdownServer(serverId)
}
}
private fun runAction(loadingMsg: String, action: suspend () -> ApiResponse<Map<String, String>>) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isActionRunning = true, actionMessage = loadingMsg)
try {
val response = action()
if (response.success) {
_uiState.value = _uiState.value.copy(
isActionRunning = false,
actionMessage = "Command executed successfully"
)
} else {
_uiState.value = _uiState.value.copy(
isActionRunning = false,
actionMessage = response.error ?: "Action failed"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isActionRunning = false,
actionMessage = e.message ?: "Network error"
)
}
}
}
fun updateCommand(text: String) { fun updateCommand(text: String) {
_uiState.value = _uiState.value.copy(commandText = text, commandOutput = null, commandError = null) _uiState.value = _uiState.value.copy(commandText = text, commandOutput = null, commandError = null)
} }
@@ -109,8 +206,13 @@ class ServerDetailViewModel : ViewModel() {
fun selectTab(tab: Int) { fun selectTab(tab: Int) {
_uiState.value = _uiState.value.copy(selectedTab = tab) _uiState.value = _uiState.value.copy(selectedTab = tab)
if (tab == 1 && _uiState.value.metricsHistory.isEmpty()) { when (tab) {
loadMetrics() 1 -> if (_uiState.value.metricsHistory.isEmpty()) loadMetrics()
3 -> if (_uiState.value.services.isEmpty() && !_uiState.value.isLoadingServices) loadServices()
} }
} }
fun clearActionMessage() {
_uiState.value = _uiState.value.copy(actionMessage = null, connectionResult = null)
}
} }

Binary file not shown.

View File

@@ -109,6 +109,10 @@ $router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand
$router->post('/api/auth/login', [ApiController::class, 'login']); $router->post('/api/auth/login', [ApiController::class, 'login']);
$router->post('/api/auth/register', [ApiController::class, 'register']); $router->post('/api/auth/register', [ApiController::class, 'register']);
$router->get('/api/app-version', [ApiController::class, 'appVersion']); $router->get('/api/app-version', [ApiController::class, 'appVersion']);
$router->get('/api/servers/:id/services', [ApiController::class, 'serverServices']);
$router->post('/api/servers/:id/reboot', [ApiController::class, 'serverReboot']);
$router->post('/api/servers/:id/shutdown', [ApiController::class, 'serverShutdown']);
$router->post('/api/servers/:id/test-connection', [ApiController::class, 'serverTestConnection']);
$router->get('/api/notifications', [ApiController::class, 'notifications']); $router->get('/api/notifications', [ApiController::class, 'notifications']);
$router->get('/api/notifications/unread-count', [ApiController::class, 'unreadCount']); $router->get('/api/notifications/unread-count', [ApiController::class, 'unreadCount']);
$router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']); $router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']);

View File

@@ -553,6 +553,138 @@ class ApiController
$this->view->json(['success' => true]); $this->view->json(['success' => true]);
} }
public function serverServices(int $id): void
{
$user = $this->authenticateRequest();
$serverModel = new Server();
if (!$serverModel->canView($id, (int) $user['id'])) {
$this->view->json(['error' => 'Server not found'], 404);
}
$server = $serverModel->findById($id);
if (!$server) {
$this->view->json(['error' => 'Server not found'], 404);
}
try {
$ssh = new SSHService();
if (!$ssh->connect($server)) {
throw new \RuntimeException('SSH connection failed');
}
$output = $ssh->exec('systemctl list-units --type=service --no-pager --no-legend 2>/dev/null | head -50');
$ssh->disconnect();
$services = [];
if ($output['exit_status'] === 0) {
foreach (explode("\n", trim($output['output'])) as $line) {
if (preg_match('/^\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/', $line, $m)) {
$services[] = [
'name' => trim($m[1]),
'load' => trim($m[2]),
'status' => trim($m[3]),
'description' => trim($m[4] ?? ''),
];
}
}
}
$this->view->json([
'success' => true,
'data' => $services,
]);
} catch (\Throwable $e) {
$this->view->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
public function serverReboot(int $id): void
{
$this->executeServerAction($id, 'reboot', '/sbin/reboot');
}
public function serverShutdown(int $id): void
{
$this->executeServerAction($id, 'shutdown', '/sbin/shutdown -h now');
}
public function serverTestConnection(int $id): void
{
$user = $this->authenticateRequest();
$serverModel = new Server();
if (!$serverModel->canView($id, (int) $user['id'])) {
$this->view->json(['error' => 'Server not found'], 404);
}
$server = $serverModel->findById($id);
if (!$server) {
$this->view->json(['error' => 'Server not found'], 404);
}
try {
$ssh = new SSHService();
$connected = $ssh->connect($server);
if ($connected) {
$ssh->disconnect();
}
$this->view->json([
'success' => true,
'data' => ['connected' => $connected],
]);
} catch (\Throwable $e) {
$this->view->json([
'success' => true,
'data' => ['connected' => false, 'error' => $e->getMessage()],
]);
}
}
private function executeServerAction(int $id, string $action, string $command): void
{
$user = $this->authenticateRequest();
$serverModel = new Server();
if (!$serverModel->canManage($id, (int) $user['id'])) {
$this->view->json(['error' => 'Forbidden'], 403);
}
$server = $serverModel->findById($id);
if (!$server) {
$this->view->json(['error' => 'Server not found'], 404);
}
try {
$ssh = new SSHService();
if (!$ssh->connect($server)) {
throw new \RuntimeException('SSH connection failed');
}
$result = $ssh->exec($command);
$ssh->disconnect();
$auditService = new AuditService();
$auditService->log("server_{$action}", 'server', $id, ['name' => $server['name']]);
$this->view->json([
'success' => true,
'message' => ucfirst($action) . ' command executed.',
'data' => [
'output' => $result['output'],
'exit_status' => $result['exit_status'],
],
]);
} catch (\Throwable $e) {
$this->view->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
public function login(): void public function login(): void
{ {
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST; $input = json_decode(file_get_contents('php://input'), true) ?? $_POST;