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
): 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")
suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>>

View File

@@ -34,3 +34,17 @@ data class LatestMetrics(
val load_average: 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> {
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
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -10,6 +9,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
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
@@ -28,6 +28,12 @@ fun DashboardScreen(
viewModel: DashboardViewModel = viewModel()
) {
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(
topBar = {
@@ -56,22 +62,6 @@ fun DashboardScreen(
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() }) {
Icon(
Icons.Default.Refresh,
@@ -124,14 +114,12 @@ fun DashboardScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
if (dashboardStats?.application != null) {
Text(
text = "${dashboardStats.application} v${dashboardStats.version ?: ""}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
}
Text(
text = "ServerManager v$appVersion",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Servers",

View File

@@ -62,52 +62,51 @@ fun ServerDetailScreen(
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.ErrorOutline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Icon(Icons.Default.ErrorOutline, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.error ?: "",
color = MaterialTheme.colorScheme.error
)
Text(text = state.error ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = { viewModel.load(serverId) }) {
Text("Retry")
}
OutlinedButton(onClick = { viewModel.load(serverId) }) { Text("Retry") }
}
}
}
else -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
TabRow(
selectedTabIndex = state.selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
contentColor = MaterialTheme.colorScheme.primary,
divider = {}
) {
Tab(
selected = state.selectedTab == 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(
selected = state.selectedTab == 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(
selected = state.selectedTab == 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)
1 -> MetricsTab(state)
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)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Icon(imageVector = Icons.Default.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = server.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(text = server.name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
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))
Text(
text = server.status.replaceFirstChar { it.uppercase() },
color = statusColor,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
}
Surface(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))
Text(text = server.status.replaceFirstChar { it.uppercase() }, color = statusColor, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium)
}
}
Spacer(modifier = Modifier.height(16.dp))
InfoRow("IP Address", server.ip_address)
InfoRow("SSH Port", server.ssh_port.toString())
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)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Memory,
contentDescription = null,
tint = Orange500
)
Icon(imageVector = Icons.Default.Memory, contentDescription = null, tint = Orange500)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Current Metrics",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(text = "Current Metrics", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
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)
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
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) {
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -265,24 +213,9 @@ private fun InfoTab(state: ServerDetailUiState) {
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.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)
)
Row(modifier = Modifier.fillMaxWidth().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)
}
@@ -296,12 +229,7 @@ private fun MetricsTab(state: ServerDetailUiState) {
return
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
@@ -309,74 +237,29 @@ private fun MetricsTab(state: ServerDetailUiState) {
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.StackedLineChart,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Icon(imageVector = Icons.Default.StackedLineChart, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Last 24 Hours",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(text = "Last 24 Hours", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(16.dp))
MetricsChart(
points = state.metricsHistory.takeLast(24),
modifier = Modifier.fillMaxWidth()
)
MetricsChart(points = state.metricsHistory.takeLast(24), modifier = Modifier.fillMaxWidth())
}
}
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))
state.metricsHistory.takeLast(12).reversed().forEach { point ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.