diff --git a/android/app/src/main/java/com/devlab/app/MainActivity.kt b/android/app/src/main/java/com/devlab/app/MainActivity.kt index 86e0e72..a2b25ac 100644 --- a/android/app/src/main/java/com/devlab/app/MainActivity.kt +++ b/android/app/src/main/java/com/devlab/app/MainActivity.kt @@ -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 } - } - } - ) } } } diff --git a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt index 353f25e..faa455a 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt @@ -44,6 +44,18 @@ interface ApiService { @Body command: CommandRequest ): ApiResponse + @GET("api/servers/{id}/services") + suspend fun getServerServices(@Path("id") id: Int): ApiResponse> + + @POST("api/servers/{id}/reboot") + suspend fun serverReboot(@Path("id") id: Int): ApiResponse> + + @POST("api/servers/{id}/shutdown") + suspend fun serverShutdown(@Path("id") id: Int): ApiResponse> + + @POST("api/servers/{id}/test-connection") + suspend fun testServerConnection(@Path("id") id: Int): ApiResponse + @GET("api/refresh-metrics") suspend fun refreshMetrics(): ApiResponse> diff --git a/android/app/src/main/java/com/devlab/app/data/model/Server.kt b/android/app/src/main/java/com/devlab/app/data/model/Server.kt index e7127f6..ed436ff 100644 --- a/android/app/src/main/java/com/devlab/app/data/model/Server.kt +++ b/android/app/src/main/java/com/devlab/app/data/model/Server.kt @@ -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 +) diff --git a/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt b/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt index 632acc5..5e50d2e 100644 --- a/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt +++ b/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt @@ -22,4 +22,20 @@ class ServerRepository { suspend fun executeCommand(id: Int, command: String): ApiResponse { return api.executeCommand(id, CommandRequest(command)) } + + suspend fun getServerServices(id: Int): ApiResponse> { + return api.getServerServices(id) + } + + suspend fun rebootServer(id: Int): ApiResponse> { + return api.serverReboot(id) + } + + suspend fun shutdownServer(id: Int): ApiResponse> { + return api.serverShutdown(id) + } + + suspend fun testConnection(id: Int): ApiResponse { + return api.testServerConnection(id) + } } diff --git a/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt b/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt index fb30f0d..b71264b 100644 --- a/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt +++ b/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt @@ -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", diff --git a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt index 6c34f50..fb633f8 100644 --- a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt @@ -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.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - 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) - ) + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceEvenly, 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 private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Terminal, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) + Icon(imageVector = Icons.Default.Terminal, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Execute Command", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) + Text(text = "Execute Command", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) } - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = state.commandText, - onValueChange = viewModel::updateCommand, + value = state.commandText, onValueChange = viewModel::updateCommand, modifier = Modifier.fillMaxWidth(), placeholder = { Text("e.g. uptime, df -h, free -m") }, - singleLine = true, - enabled = !state.isExecuting, - leadingIcon = { - Icon(Icons.Default.Terminal, contentDescription = null) - } + singleLine = true, enabled = !state.isExecuting, + leadingIcon = { Icon(Icons.Default.Terminal, contentDescription = null) } ) - 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) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp - ) + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp) } else { - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) + Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp)) Spacer(modifier = Modifier.width(6.dp)) Text("Execute") } } - if (state.commandOutput != null) { Spacer(modifier = Modifier.height(16.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Grey900) - ) { + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Grey900)) { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = Green500, - modifier = Modifier.size(16.dp) - ) + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Green500, modifier = Modifier.size(16.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)) - Text( - text = state.commandOutput ?: "", - color = Green500, - style = MaterialTheme.typography.bodyMedium, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace - ) + Text(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) + } + } + } + } +} + +@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)) + + 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( modifier = Modifier.fillMaxWidth(), 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(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( - text = "Error", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelSmall - ) - } - Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (isSuccess) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (isSuccess) Green500 else MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = state.commandError ?: "", - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodyMedium, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + text = state.actionMessage ?: "", + color = if (isSuccess) Green500 else MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium ) } } @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt index a61d312..6224ec8 100644 --- a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt @@ -19,7 +19,14 @@ data class ServerDetailUiState( val commandOutput: String? = null, val commandError: String? = null, val isExecuting: Boolean = false, - val selectedTab: Int = 0 + val selectedTab: Int = 0, + val services: List = 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() { @@ -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>) { + 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) { _uiState.value = _uiState.value.copy(commandText = text, commandOutput = null, commandError = null) } @@ -109,8 +206,13 @@ class ServerDetailViewModel : ViewModel() { fun selectTab(tab: Int) { _uiState.value = _uiState.value.copy(selectedTab = tab) - if (tab == 1 && _uiState.value.metricsHistory.isEmpty()) { - loadMetrics() + when (tab) { + 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) + } } diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 0133be1..bd74508 100755 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -2840,3 +2840,234 @@ textarea.form-input { .nav-sub-item .status-dot-sm { 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); +} diff --git a/public/sysadmin.apk b/public/sysadmin.apk index 874d676..b238113 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/routes/web.php b/routes/web.php index 2bb53a2..25ab178 100755 --- a/routes/web.php +++ b/routes/web.php @@ -67,6 +67,7 @@ $router->get('/servers/:id/metrics', [ServerController::class, 'metrics'], [Auth $router->get('/sidebar/servers', [ServerController::class, 'sidebarServers'], [AuthMiddleware::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->post('/teams/create', [TeamController::class, 'store'], [AuthMiddleware::class, CSRFMiddleware::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/register', [ApiController::class, 'register']); $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/unread-count', [ApiController::class, 'unreadCount']); $router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']); diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 850e07b..13c7f23 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -553,6 +553,138 @@ class ApiController $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 { $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; diff --git a/src/Controllers/TeamController.php b/src/Controllers/TeamController.php index bf47522..4af51c1 100644 --- a/src/Controllers/TeamController.php +++ b/src/Controllers/TeamController.php @@ -30,14 +30,53 @@ class TeamController public function index(): void { $userId = (int) Session::get('user_id'); - $teams = $this->teamModel->findByCreator($userId); + $search = $_GET['search'] ?? ''; + + if ($search !== '') { + $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', [ 'title' => 'Teams - ServerManager', '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 { $this->view->display('teams.create', [ diff --git a/views/teams/index.php b/views/teams/index.php index b617480..041fbb4 100644 --- a/views/teams/index.php +++ b/views/teams/index.php @@ -1,4 +1,4 @@ - + -
-
- -
- -

No teams created yet.

- Create Your First Team -
- -
- - - - - - - - - - - - - - - - - - - - - -
NameDescriptionMembersServersActions
- - - - -
- - - - -
-
-
+
+
+ + + +
+ +
+
+
+ +
+

+ +

+
+

+ +

+
+ + members + + + servers + +
+
+ + Manage + + +
+
+ +
+ +