Merge pull request 'feat/android-app-redesign' (#26) from feat/android-app-redesign into master
Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?>>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2840,3 +2840,234 @@ textarea.form-input {
|
|||||||
.nav-sub-item .status-dot-sm {
|
.nav-sub-item .status-dot-sm {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
Teams — Card Grid
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.teams-toolbar {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-search {
|
||||||
|
position: relative;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-search-input {
|
||||||
|
padding-left: 36px !important;
|
||||||
|
padding-right: 36px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-search-clear:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
animation: teamCardEnter 0.35s ease both;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card:nth-child(1) { animation-delay: 0.00s; }
|
||||||
|
.team-card:nth-child(2) { animation-delay: 0.05s; }
|
||||||
|
.team-card:nth-child(3) { animation-delay: 0.10s; }
|
||||||
|
.team-card:nth-child(4) { animation-delay: 0.15s; }
|
||||||
|
.team-card:nth-child(5) { animation-delay: 0.20s; }
|
||||||
|
.team-card:nth-child(6) { animation-delay: 0.25s; }
|
||||||
|
.team-card:nth-child(7) { animation-delay: 0.30s; }
|
||||||
|
.team-card:nth-child(8) { animation-delay: 0.35s; }
|
||||||
|
.team-card:nth-child(9) { animation-delay: 0.40s; }
|
||||||
|
.team-card:nth-child(10) { animation-delay: 0.45s; }
|
||||||
|
|
||||||
|
.team-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes teamCardEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(15px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-title a {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-title a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-desc {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-stat i {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
Autocomplete — User Search
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.autocomplete {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-dropdown.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-item {
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-item:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-item strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-item small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-empty {
|
||||||
|
padding: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -67,6 +67,7 @@ $router->get('/servers/:id/metrics', [ServerController::class, 'metrics'], [Auth
|
|||||||
$router->get('/sidebar/servers', [ServerController::class, 'sidebarServers'], [AuthMiddleware::class]);
|
$router->get('/sidebar/servers', [ServerController::class, 'sidebarServers'], [AuthMiddleware::class]);
|
||||||
|
|
||||||
$router->get('/teams', [TeamController::class, 'index'], [AuthMiddleware::class, RoleMiddleware::class]);
|
$router->get('/teams', [TeamController::class, 'index'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||||
|
$router->get('/teams/search-users', [TeamController::class, 'searchUsers'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||||
$router->get('/teams/create', [TeamController::class, 'create'], [AuthMiddleware::class, RoleMiddleware::class]);
|
$router->get('/teams/create', [TeamController::class, 'create'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||||
$router->post('/teams/create', [TeamController::class, 'store'], [AuthMiddleware::class, CSRFMiddleware::class, RoleMiddleware::class]);
|
$router->post('/teams/create', [TeamController::class, 'store'], [AuthMiddleware::class, CSRFMiddleware::class, RoleMiddleware::class]);
|
||||||
$router->get('/teams/:id', [TeamController::class, 'show'], [AuthMiddleware::class, RoleMiddleware::class]);
|
$router->get('/teams/:id', [TeamController::class, 'show'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||||
@@ -108,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']);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -30,14 +30,53 @@ class TeamController
|
|||||||
public function index(): void
|
public function index(): void
|
||||||
{
|
{
|
||||||
$userId = (int) Session::get('user_id');
|
$userId = (int) Session::get('user_id');
|
||||||
|
$search = $_GET['search'] ?? '';
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
$teams = $this->teamModel->findByCreator($userId);
|
$teams = $this->teamModel->findByCreator($userId);
|
||||||
|
$teams = array_filter($teams, function ($team) use ($search) {
|
||||||
|
$s = mb_strtolower($search);
|
||||||
|
return mb_strpos(mb_strtolower($team['name'] ?? ''), $s) !== false
|
||||||
|
|| mb_strpos(mb_strtolower($team['description'] ?? ''), $s) !== false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$teams = $this->teamModel->findByCreator($userId);
|
||||||
|
}
|
||||||
|
|
||||||
$this->view->display('teams.index', [
|
$this->view->display('teams.index', [
|
||||||
'title' => 'Teams - ServerManager',
|
'title' => 'Teams - ServerManager',
|
||||||
'teams' => $teams,
|
'teams' => $teams,
|
||||||
|
'search' => $search,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function searchUsers(): void
|
||||||
|
{
|
||||||
|
$q = $_GET['q'] ?? '';
|
||||||
|
if (mb_strlen($q) < 2) {
|
||||||
|
$this->view->json(['users' => []]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userModel = new User();
|
||||||
|
$allUsers = $userModel->getAll(1, 999);
|
||||||
|
$filtered = array_filter($allUsers['data'], function ($u) use ($q) {
|
||||||
|
$s = mb_strtolower($q);
|
||||||
|
return mb_strpos(mb_strtolower($u['username'] ?? ''), $s) !== false
|
||||||
|
|| mb_strpos(mb_strtolower($u['email'] ?? ''), $s) !== false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$results = array_map(function ($u) {
|
||||||
|
return [
|
||||||
|
'id' => (int) $u['id'],
|
||||||
|
'username' => $u['username'],
|
||||||
|
'email' => $u['email'],
|
||||||
|
];
|
||||||
|
}, array_slice(array_values($filtered), 0, 20));
|
||||||
|
|
||||||
|
$this->view->json(['users' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(): void
|
public function create(): void
|
||||||
{
|
{
|
||||||
$this->view->display('teams.create', [
|
$this->view->display('teams.create', [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?php $teams = $teams ?? []; ?>
|
<?php $teams = $teams ?? []; $search = $search ?? ''; ?>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-header-left">
|
<div class="page-header-left">
|
||||||
@@ -12,62 +12,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="teams-toolbar">
|
||||||
<div class="card-body">
|
<div class="teams-search">
|
||||||
|
<i class="fas fa-search teams-search-icon"></i>
|
||||||
|
<input type="text" id="teamSearch" class="form-input teams-search-input"
|
||||||
|
placeholder="Search teams..." value="<?= htmlspecialchars($search) ?>">
|
||||||
|
<?php if ($search !== ''): ?>
|
||||||
|
<button class="teams-search-clear" onclick="clearSearch()">×</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php if (empty($teams)): ?>
|
<?php if (empty($teams)): ?>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-users-cog"></i>
|
<i class="fas fa-users-cog"></i>
|
||||||
<p>No teams created yet.</p>
|
<p><?= $search !== '' ? 'No teams match your search.' : 'No teams created yet.' ?></p>
|
||||||
|
<?php if ($search !== ''): ?>
|
||||||
|
<a href="/teams" class="btn btn-secondary">Clear Search</a>
|
||||||
|
<?php else: ?>
|
||||||
<a href="/teams/create" class="btn btn-primary">Create Your First Team</a>
|
<a href="/teams/create" class="btn btn-primary">Create Your First Team</a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="table-responsive">
|
<div class="teams-grid" id="teamsGrid">
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Members</th>
|
|
||||||
<th>Servers</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($teams as $team): ?>
|
<?php foreach ($teams as $team): ?>
|
||||||
<tr>
|
<div class="team-card">
|
||||||
<td>
|
<div class="team-card-header">
|
||||||
<a href="/teams/<?= $team['id'] ?>" class="fw-semibold">
|
<div class="team-card-icon">
|
||||||
<?= htmlspecialchars($team['name']) ?>
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="team-card-title">
|
||||||
|
<a href="/teams/<?= $team['id'] ?>"><?= htmlspecialchars($team['name']) ?></a>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="team-card-desc">
|
||||||
|
<?= htmlspecialchars(mb_substr($team['description'] ?? 'No description', 0, 100)) ?>
|
||||||
|
</p>
|
||||||
|
<div class="team-card-stats">
|
||||||
|
<span class="team-stat">
|
||||||
|
<i class="fas fa-user"></i> <?= (int) ($team['member_count'] ?? 0) ?> members
|
||||||
|
</span>
|
||||||
|
<span class="team-stat">
|
||||||
|
<i class="fas fa-server"></i> <?= (int) ($team['server_count'] ?? 0) ?> servers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="team-card-actions">
|
||||||
|
<a href="/teams/<?= $team['id'] ?>" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-cog"></i> Manage
|
||||||
</a>
|
</a>
|
||||||
</td>
|
<button class="btn btn-sm btn-danger" title="Delete"
|
||||||
<td class="text-muted"><?= htmlspecialchars(mb_substr($team['description'] ?? '', 0, 80)) ?></td>
|
|
||||||
<td><span class="badge badge-info"><?= (int) ($team['member_count'] ?? 0) ?></span></td>
|
|
||||||
<td><span class="badge badge-primary"><?= (int) ($team['server_count'] ?? 0) ?></span></td>
|
|
||||||
<td>
|
|
||||||
<div class="btn-group">
|
|
||||||
<a href="/teams/<?= $team['id'] ?>" class="btn btn-xs" title="Manage">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-xs btn-danger" title="Delete"
|
|
||||||
onclick="deleteTeam(<?= $team['id'] ?>, '<?= htmlspecialchars(addslashes($team['name'])) ?>')">
|
onclick="deleteTeam(<?= $team['id'] ?>, '<?= htmlspecialchars(addslashes($team['name'])) ?>')">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="deleteForm" method="POST" style="display:none">
|
<form id="deleteForm" method="POST" style="display:none">
|
||||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
document.getElementById('teamSearch')?.addEventListener('input', function() {
|
||||||
|
const val = this.value.trim();
|
||||||
|
const url = val ? '/teams?search=' + encodeURIComponent(val) : '/teams';
|
||||||
|
window.location.href = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
window.location.href = '/teams';
|
||||||
|
}
|
||||||
|
|
||||||
function deleteTeam(id, name) {
|
function deleteTeam(id, name) {
|
||||||
if (confirm('Delete team "' + name + '"?\nThis will remove access for all members.')) {
|
if (confirm('Delete team "' + name + '"?\nThis will remove access for all members.')) {
|
||||||
const form = document.getElementById('deleteForm');
|
const form = document.getElementById('deleteForm');
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
$team = $team ?? [];
|
$team = $team ?? [];
|
||||||
$members = $members ?? [];
|
$members = $members ?? [];
|
||||||
$servers = $servers ?? [];
|
$servers = $servers ?? [];
|
||||||
$availableUsers = $availableUsers ?? [];
|
|
||||||
$availableServers = $availableServers ?? [];
|
$availableServers = $availableServers ?? [];
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ $availableServers = $availableServers ?? [];
|
|||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="membersList">
|
||||||
<?php foreach ($members as $member): ?>
|
<?php foreach ($members as $member): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -58,24 +57,16 @@ $availableServers = $availableServers ?? [];
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($availableUsers)): ?>
|
<div class="autocomplete-wrapper" style="margin-top:1rem">
|
||||||
<form class="form-inline" style="margin-top:1rem;display:flex;gap:0.5rem;align-items:end" onsubmit="addMember(event)">
|
<label class="form-label">Add Member</label>
|
||||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
<div class="autocomplete" id="userAutocomplete">
|
||||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
<input type="text" class="form-input autocomplete-input"
|
||||||
<select id="addUserId" class="form-select" required>
|
id="addUserInput" placeholder="Search users..."
|
||||||
<option value="">Add user...</option>
|
autocomplete="off">
|
||||||
<?php foreach ($availableUsers as $user): ?>
|
<div class="autocomplete-dropdown" id="userDropdown"></div>
|
||||||
<option value="<?= $user['id'] ?>">
|
</div>
|
||||||
<?= htmlspecialchars($user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)
|
<small class="form-hint">Type at least 2 characters to search users.</small>
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
<i class="fas fa-plus"></i> Add
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,13 +178,66 @@ function deleteTeam() {
|
|||||||
document.getElementById('postForm').submit();
|
document.getElementById('postForm').submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMember(e) {
|
/* ───── Autocomplete para usuarios ───── */
|
||||||
e.preventDefault();
|
let searchTimeout = null;
|
||||||
const userId = document.getElementById('addUserId').value;
|
let selectedUserId = null;
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
|
document.getElementById('addUserInput').addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
const q = this.value.trim();
|
||||||
|
selectedUserId = null;
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
document.getElementById('userDropdown').classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(function() {
|
||||||
|
fetch('/teams/search-users?q=' + encodeURIComponent(q), {
|
||||||
|
headers: { 'X-CSRF-Token': getCsrfToken() }
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const dropdown = document.getElementById('userDropdown');
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
if (data.users && data.users.length > 0) {
|
||||||
|
data.users.forEach(function(u) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'autocomplete-item';
|
||||||
|
item.innerHTML = '<strong>' + escapeHtml(u.username) + '</strong> <small>' + escapeHtml(u.email) + '</small>';
|
||||||
|
item.dataset.id = u.id;
|
||||||
|
item.dataset.username = u.username;
|
||||||
|
item.addEventListener('click', function() {
|
||||||
|
selectUser(u.id, u.username);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
dropdown.classList.add('active');
|
||||||
|
} else {
|
||||||
|
dropdown.innerHTML = '<div class="autocomplete-empty">No users found</div>';
|
||||||
|
dropdown.classList.add('active');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('userDropdown').classList.remove('active');
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('.autocomplete')) {
|
||||||
|
document.getElementById('userDropdown').classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectUser(id, username) {
|
||||||
|
selectedUserId = id;
|
||||||
|
document.getElementById('addUserInput').value = username;
|
||||||
|
document.getElementById('userDropdown').classList.remove('active');
|
||||||
|
|
||||||
|
// Auto-add the user
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('user_id', userId);
|
form.append('user_id', id);
|
||||||
form.append('_csrf_token', getCsrfToken());
|
form.append('_csrf_token', getCsrfToken());
|
||||||
|
|
||||||
fetch('/teams/<?= $team['id'] ?>/add-user', {
|
fetch('/teams/<?= $team['id'] ?>/add-user', {
|
||||||
@@ -203,11 +247,17 @@ function addMember(e) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
if (res.success) {
|
||||||
else { showToast('error', res.message); }
|
showToast('success', username + ' added to team');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
showToast('error', res.message);
|
||||||
|
document.getElementById('addUserInput').value = '';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───── Funciones existentes ───── */
|
||||||
function removeMember(userId, username) {
|
function removeMember(userId, username) {
|
||||||
if (!confirm('Remove "' + username + '" from this team?')) return;
|
if (!confirm('Remove "' + username + '" from this team?')) return;
|
||||||
|
|
||||||
@@ -286,4 +336,10 @@ function updateServerRole(serverId, role) {
|
|||||||
else { showToast('error', res.message); }
|
else { showToast('error', res.message); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user