diff --git a/.gitignore b/.gitignore index 99d232f..ed3c714 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ .idea/ logs/ *.log +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..e8ca6aa --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "com.devlab.app" + compileSdk = 37 + + defaultConfig { + applicationId = "com.devlab.app" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2026.05.01") + implementation(composeBom) + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + implementation("androidx.core:core-ktx:1.19.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") + implementation("androidx.activity:activity-compose:1.13.0") + + implementation("androidx.navigation:navigation-compose:2.9.8") + + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") + + implementation("androidx.datastore:datastore-preferences:1.1.3") + + implementation("androidx.core:core-splashscreen:1.0.1") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..a1971ab --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# Project-wide ProGuard rules +-keepattributes Signature +-keepattributes *Annotation* +-keep class kotlinx.serialization.** { *; } +-keepclassmembers class com.devlab.app.data.model.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0f6f5a9 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/devlab/app/MainActivity.kt b/android/app/src/main/java/com/devlab/app/MainActivity.kt new file mode 100644 index 0000000..492b47e --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/MainActivity.kt @@ -0,0 +1,224 @@ +package com.devlab.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.devlab.app.data.api.SessionManager +import com.devlab.app.navigation.Screen +import com.devlab.app.ui.dashboard.DashboardScreen +import com.devlab.app.ui.login.LoginScreen +import com.devlab.app.ui.login.LoginViewModel +import com.devlab.app.ui.profile.ProfileScreen +import com.devlab.app.ui.register.RegisterScreen +import com.devlab.app.ui.servers.ServerDetailScreen +import com.devlab.app.ui.servers.ServerListScreen +import com.devlab.app.ui.theme.ServerManagerTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ServerManagerTheme { + AppRoot() + } + } + } +} + +@Composable +fun AppRoot() { + val navController = rememberNavController() + val loginViewModel: LoginViewModel = viewModel() + var isLoggedIn by remember { mutableStateOf(false) } + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + val inLoggedInArea = currentDestination?.route in listOf( + Screen.Dashboard.route, + Screen.ServerList.route, + Screen.Profile.route + ) + + LaunchedEffect(Unit) { + SessionManager.onLogoutRequest.collect { requested -> + if (requested) { + loginViewModel.logout() + isLoggedIn = false + navController.navigate(Screen.Login.route) { + popUpTo(0) { inclusive = true } + } + SessionManager.consumeLogoutRequest() + SessionManager.restoreSession() + } + } + } + + Scaffold( + bottomBar = { + if (inLoggedInArea) { + NavigationBar { + NavigationBarItem( + icon = { + Icon( + Icons.Default.Dashboard, + contentDescription = null, + modifier = if (currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true) + Modifier.padding(0.dp) else Modifier + ) + }, + label = { Text("Dashboard") }, + selected = currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true, + onClick = { + navController.navigate(Screen.Dashboard.route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Dns, contentDescription = null) }, + label = { Text("Servers") }, + selected = currentDestination?.hierarchy?.any { it.route == Screen.ServerList.route } == true, + onClick = { + navController.navigate(Screen.ServerList.route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + ) + 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 } + } + } + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = if (isLoggedIn) Screen.Dashboard.route else Screen.Login.route, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + enterTransition = { slideInHorizontally(initialOffsetX = { it / 4 }) + fadeIn(animationSpec = tween(300)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) }, + popEnterTransition = { fadeIn(animationSpec = tween(200)) }, + popExitTransition = { slideOutHorizontally(targetOffsetX = { it / 4 }) + fadeOut(animationSpec = tween(200)) } + ) { + composable(Screen.Login.route) { + LoginScreen( + onConnected = { + isLoggedIn = true + navController.navigate(Screen.Dashboard.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onRegisterClick = { + navController.navigate(Screen.Register.route) + }, + viewModel = loginViewModel + ) + } + + composable(Screen.Register.route) { + RegisterScreen( + onRegistered = { + isLoggedIn = true + navController.navigate(Screen.Dashboard.route) { + popUpTo(Screen.Register.route) { inclusive = true } + } + }, + onLoginClick = { + navController.popBackStack() + } + ) + } + + composable(Screen.Dashboard.route) { + DashboardScreen( + onLogout = { + loginViewModel.logout() + isLoggedIn = false + navController.navigate(Screen.Login.route) { + popUpTo(0) { inclusive = true } + } + }, + onProfileClick = { + navController.navigate(Screen.Profile.route) + } + ) + } + + composable(Screen.ServerList.route) { + ServerListScreen( + onServerClick = { serverId -> + navController.navigate(Screen.ServerDetail.createRoute(serverId)) + } + ) + } + + composable(Screen.Profile.route) { + ProfileScreen( + onBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.ServerDetail.route, + arguments = listOf(navArgument("serverId") { type = NavType.IntType }) + ) { backStackEntry -> + val serverId = backStackEntry.arguments?.getInt("serverId") ?: 0 + ServerDetailScreen( + serverId = serverId, + onBack = { navController.popBackStack() } + ) + } + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt new file mode 100644 index 0000000..4a288b3 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt @@ -0,0 +1,15 @@ +package com.devlab.app + +import android.app.Application + +class ServerManagerApp : Application() { + override fun onCreate() { + super.onCreate() + instance = this + } + + companion object { + lateinit var instance: ServerManagerApp + private set + } +} 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 new file mode 100644 index 0000000..2a96e41 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt @@ -0,0 +1,49 @@ +package com.devlab.app.data.api + +import com.devlab.app.data.model.* +import retrofit2.http.* + +interface ApiService { + + @POST("api/auth/login") + suspend fun login(@Body request: LoginRequest): LoginResponse + + @POST("api/auth/register") + suspend fun register(@Body request: RegisterRequest): LoginResponse + + @GET("api/profile") + suspend fun getProfile(): ApiResponse + + @PUT("api/profile") + suspend fun updateProfile(@Body request: ProfileUpdateRequest): ApiResponse> + + @GET("api/status") + suspend fun getStatus(): ApiResponse + + @GET("api/servers") + suspend fun getServers( + @Query("page") page: Int = 1, + @Query("per_page") perPage: Int = 20, + @Query("search") search: String? = null + ): ApiResponse> + + @GET("api/servers/{id}") + suspend fun getServerDetail( + @Path("id") id: Int + ): ApiResponse + + @GET("api/servers/{id}/metrics") + suspend fun getServerMetrics( + @Path("id") id: Int, + @Query("hours") hours: Int = 24 + ): ApiResponse> + + @POST("api/servers/{id}/execute") + suspend fun executeCommand( + @Path("id") id: Int, + @Body command: CommandRequest + ): ApiResponse + + @GET("api/refresh-metrics") + suspend fun refreshMetrics(): ApiResponse> +} diff --git a/android/app/src/main/java/com/devlab/app/data/api/AuthInterceptor.kt b/android/app/src/main/java/com/devlab/app/data/api/AuthInterceptor.kt new file mode 100644 index 0000000..3da33ab --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/api/AuthInterceptor.kt @@ -0,0 +1,20 @@ +package com.devlab.app.data.api + +import okhttp3.Interceptor +import okhttp3.Response + +class AuthInterceptor(private val token: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $token") + .addHeader("Accept", "application/json") + .build() + val response = chain.proceed(request) + + if (response.code == 401 && token.isNotBlank()) { + SessionManager.invalidateSession() + } + + return response + } +} diff --git a/android/app/src/main/java/com/devlab/app/data/api/RetrofitClient.kt b/android/app/src/main/java/com/devlab/app/data/api/RetrofitClient.kt new file mode 100644 index 0000000..6eac1f1 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/api/RetrofitClient.kt @@ -0,0 +1,78 @@ +package com.devlab.app.data.api + +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitClient { + + const val BASE_URL = "https://servermanager.devlab.lat/" + + private var apiToken: String = "" + private var retrofit: Retrofit? = null + private var apiService: ApiService? = null + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + fun setToken(token: String) { + if (token != apiToken) { + apiToken = token + retrofit = null + apiService = null + } + } + + fun getApiService(): ApiService { + if (apiService == null) { + val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + val client = OkHttpClient.Builder() + .addInterceptor(AuthInterceptor(apiToken)) + .addInterceptor(logging) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + apiService = retrofit!!.create(ApiService::class.java) + } + return apiService!! + } + + fun getUnauthenticatedService(): ApiService { + val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + val client = OkHttpClient.Builder() + .addInterceptor(logging) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ApiService::class.java) + } + + fun isConfigured(): Boolean = apiToken.isNotBlank() +} diff --git a/android/app/src/main/java/com/devlab/app/data/api/SessionManager.kt b/android/app/src/main/java/com/devlab/app/data/api/SessionManager.kt new file mode 100644 index 0000000..e37061a --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/api/SessionManager.kt @@ -0,0 +1,29 @@ +package com.devlab.app.data.api + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object SessionManager { + + private val _isSessionValid = MutableStateFlow(true) + val isSessionValid: StateFlow = _isSessionValid.asStateFlow() + + private val _onLogoutRequest = MutableStateFlow(false) + val onLogoutRequest: StateFlow = _onLogoutRequest.asStateFlow() + + fun invalidateSession() { + _isSessionValid.value = false + _onLogoutRequest.value = true + RetrofitClient.setToken("") + } + + fun restoreSession() { + _isSessionValid.value = true + _onLogoutRequest.value = false + } + + fun consumeLogoutRequest() { + _onLogoutRequest.value = false + } +} diff --git a/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt new file mode 100644 index 0000000..e4e5c2c --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt @@ -0,0 +1,19 @@ +package com.devlab.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Pagination( + val page: Int = 1, + val per_page: Int = 20, + val total: Int = 0, + val total_pages: Int = 0 +) + +@Serializable +data class ApiResponse( + val success: Boolean, + val data: T? = null, + val error: String? = null, + val pagination: Pagination? = null +) diff --git a/android/app/src/main/java/com/devlab/app/data/model/CommandRequest.kt b/android/app/src/main/java/com/devlab/app/data/model/CommandRequest.kt new file mode 100644 index 0000000..e23784c --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/CommandRequest.kt @@ -0,0 +1,15 @@ +package com.devlab.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CommandRequest( + val command: String +) + +@Serializable +data class CommandResult( + val output: String? = null, + val exit_status: Int? = null, + val stderr: String? = null +) diff --git a/android/app/src/main/java/com/devlab/app/data/model/DashboardStats.kt b/android/app/src/main/java/com/devlab/app/data/model/DashboardStats.kt new file mode 100644 index 0000000..953b926 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/DashboardStats.kt @@ -0,0 +1,20 @@ +package com.devlab.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DashboardStats( + val application: String? = null, + val version: String? = null, + val stats: Stats? = null +) + +@Serializable +data class Stats( + val total_servers: Int = 0, + val online_servers: Int = 0, + val offline_servers: Int = 0, + val avg_cpu: Double = 0.0, + val avg_ram: Double = 0.0, + val avg_disk: Double = 0.0 +) diff --git a/android/app/src/main/java/com/devlab/app/data/model/LoginModels.kt b/android/app/src/main/java/com/devlab/app/data/model/LoginModels.kt new file mode 100644 index 0000000..d588fec --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/LoginModels.kt @@ -0,0 +1,55 @@ +package com.devlab.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val username: String, + val password: String +) + +@Serializable +data class RegisterRequest( + val username: String, + val email: String, + val password: String, + val confirm_password: String +) + +@Serializable +data class ProfileUpdateRequest( + val email: String, + val current_password: String? = null, + val new_password: String? = null, + val confirm_password: String? = null +) + +@Serializable +data class LoginResponse( + val success: Boolean, + val data: LoginData? = null, + val error: String? = null +) + +@Serializable +data class LoginData( + val token: String = "", + val user: LoginUser? = null +) + +@Serializable +data class LoginUser( + val id: Int = 0, + val username: String = "", + val email: String = "", + val role: String = "" +) + +@Serializable +data class UserProfile( + val id: Int = 0, + val username: String = "", + val email: String = "", + val role: String = "", + val created_at: String? = null +) diff --git a/android/app/src/main/java/com/devlab/app/data/model/MetricPoint.kt b/android/app/src/main/java/com/devlab/app/data/model/MetricPoint.kt new file mode 100644 index 0000000..2c71d9e --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/MetricPoint.kt @@ -0,0 +1,12 @@ +package com.devlab.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MetricPoint( + val cpu_usage: Double? = null, + val ram_usage: Double? = null, + val disk_usage: Double? = null, + val load_average: String? = null, + val recorded_at: String? = null +) diff --git a/android/app/src/main/java/com/devlab/app/data/model/ProcessInfo.kt b/android/app/src/main/java/com/devlab/app/data/model/ProcessInfo.kt new file mode 100644 index 0000000..bf4a49a --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/ProcessInfo.kt @@ -0,0 +1,13 @@ +package com.devlab.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ProcessInfo( + val pid: Int = 0, + val name: String = "", + val cpu_percent: Double? = null, + val memory_percent: Double? = null, + val status: String? = null, + val user: String? = null +) 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 new file mode 100644 index 0000000..e7127f6 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/Server.kt @@ -0,0 +1,36 @@ +package com.devlab.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Server( + val id: Int = 0, + val name: String = "", + val ip_address: String = "", + val ssh_port: Int = 22, + val status: String = "unknown", + val group_name: String? = null, + val description: String? = null, + val cpu_usage: Double? = null, + val ram_usage: Double? = null, + val disk_usage: Double? = null, + val load_average: String? = null, + val uptime: String? = null, + val created_at: String? = null, + val created_by_name: String? = null +) + +@Serializable +data class ServerDetail( + val server: Server? = null, + val metrics: LatestMetrics? = null +) + +@Serializable +data class LatestMetrics( + val cpu_usage: Double? = null, + val ram_usage: Double? = null, + val disk_usage: Double? = null, + val load_average: String? = null, + val uptime: String? = null +) diff --git a/android/app/src/main/java/com/devlab/app/data/repository/AuthRepository.kt b/android/app/src/main/java/com/devlab/app/data/repository/AuthRepository.kt new file mode 100644 index 0000000..591cfa1 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/repository/AuthRepository.kt @@ -0,0 +1,56 @@ +package com.devlab.app.data.repository + +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.model.LoginRequest +import com.devlab.app.data.model.RegisterRequest + +class AuthRepository { + + suspend fun login(username: String, password: String): Result { + return try { + val service = RetrofitClient.getUnauthenticatedService() + val response = service.login(LoginRequest(username, password)) + if (response.success && response.data != null) { + val token = response.data.token + val user = response.data.user + if (token.isNotBlank() && user != null) { + Result.success(LoginResult(token, user.id, user.username, user.email, user.role)) + } else { + Result.failure(Exception("Empty token received")) + } + } else { + Result.failure(Exception(response.error ?: "Authentication failed")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun register(username: String, email: String, password: String, confirmPassword: String): Result { + return try { + val service = RetrofitClient.getUnauthenticatedService() + val response = service.register(RegisterRequest(username, email, password, confirmPassword)) + if (response.success && response.data != null) { + val token = response.data.token + val user = response.data.user + if (token.isNotBlank() && user != null) { + Result.success(LoginResult(token, user.id, user.username, user.email, user.role)) + } else { + Result.failure(Exception("Registration succeeded but empty token")) + } + } else { + Result.failure(Exception(response.error ?: "Registration failed")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} + +data class LoginResult( + val token: String, + val userId: Int, + val username: String, + val email: String, + val role: String +) diff --git a/android/app/src/main/java/com/devlab/app/data/repository/DashboardRepository.kt b/android/app/src/main/java/com/devlab/app/data/repository/DashboardRepository.kt new file mode 100644 index 0000000..e6b8939 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/repository/DashboardRepository.kt @@ -0,0 +1,14 @@ +package com.devlab.app.data.repository + +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.model.ApiResponse +import com.devlab.app.data.model.DashboardStats + +class DashboardRepository { + + private val api get() = RetrofitClient.getApiService() + + suspend fun getStatus(): ApiResponse { + return api.getStatus() + } +} diff --git a/android/app/src/main/java/com/devlab/app/data/repository/ProfileRepository.kt b/android/app/src/main/java/com/devlab/app/data/repository/ProfileRepository.kt new file mode 100644 index 0000000..d4173d8 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/repository/ProfileRepository.kt @@ -0,0 +1,46 @@ +package com.devlab.app.data.repository + +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.model.ProfileUpdateRequest + +class ProfileRepository { + + private val api get() = RetrofitClient.getApiService() + + suspend fun getProfile(): Result { + return try { + val response = api.getProfile() + if (response.success && response.data != null) { + Result.success(response.data) + } else { + Result.failure(Exception(response.error ?: "Failed to load profile")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateProfile( + email: String, + currentPassword: String?, + newPassword: String?, + confirmPassword: String? + ): Result { + return try { + val request = ProfileUpdateRequest( + email = email, + current_password = currentPassword?.takeIf { it.isNotBlank() }, + new_password = newPassword?.takeIf { it.isNotBlank() }, + confirm_password = confirmPassword?.takeIf { it.isNotBlank() } + ) + val response = api.updateProfile(request) + if (response.success) { + Result.success(response.data?.get("message") ?: "Profile updated") + } else { + Result.failure(Exception(response.error ?: "Update failed")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} 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 new file mode 100644 index 0000000..632acc5 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt @@ -0,0 +1,25 @@ +package com.devlab.app.data.repository + +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.model.* + +class ServerRepository { + + private val api get() = RetrofitClient.getApiService() + + suspend fun getServers(page: Int = 1, perPage: Int = 20, search: String? = null): ApiResponse> { + return api.getServers(page, perPage, search) + } + + suspend fun getServerDetail(id: Int): ApiResponse { + return api.getServerDetail(id) + } + + suspend fun getServerMetrics(id: Int, hours: Int = 24): ApiResponse> { + return api.getServerMetrics(id, hours) + } + + suspend fun executeCommand(id: Int, command: String): ApiResponse { + return api.executeCommand(id, CommandRequest(command)) + } +} diff --git a/android/app/src/main/java/com/devlab/app/navigation/NavGraph.kt b/android/app/src/main/java/com/devlab/app/navigation/NavGraph.kt new file mode 100644 index 0000000..1c45e53 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/navigation/NavGraph.kt @@ -0,0 +1,12 @@ +package com.devlab.app.navigation + +sealed class Screen(val route: String) { + data object Login : Screen("login") + data object Register : Screen("register") + data object Dashboard : Screen("dashboard") + data object ServerList : Screen("servers") + data object Profile : Screen("profile") + data object ServerDetail : Screen("servers/{serverId}") { + fun createRoute(serverId: Int) = "servers/$serverId" + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/components/LoadingIndicator.kt b/android/app/src/main/java/com/devlab/app/ui/components/LoadingIndicator.kt new file mode 100644 index 0000000..7bd882f --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/components/LoadingIndicator.kt @@ -0,0 +1,70 @@ +package com.devlab.app.ui.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp + +@Composable +fun ShimmerCard(modifier: Modifier = Modifier) { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer" + ) + + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset.Zero, + end = Offset(x = translateAnim, y = translateAnim) + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(12.dp)) + .background(brush) + ) + } +} + +@Composable +fun LoadingIndicator(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + repeat(4) { + ShimmerCard() + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/components/MetricsChart.kt b/android/app/src/main/java/com/devlab/app/ui/components/MetricsChart.kt new file mode 100644 index 0000000..f26d61d --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/components/MetricsChart.kt @@ -0,0 +1,157 @@ +package com.devlab.app.ui.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.devlab.app.data.model.MetricPoint +import com.devlab.app.ui.theme.* + +@Composable +fun MetricsChart( + points: List, + modifier: Modifier = Modifier +) { + if (points.isEmpty()) { + Box(modifier = modifier.fillMaxWidth().height(200.dp)) { + Text( + text = "No metrics data available", + color = Grey500, + modifier = Modifier.padding(16.dp) + ) + } + return + } + + val animationProgress by animateFloatAsState( + targetValue = 1f, + animationSpec = tween(1500, easing = FastOutSlowInEasing), + label = "chartAnim" + ) + + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + LegendItem(color = Orange500, label = "CPU") + LegendItem(color = Blue700, label = "RAM") + LegendItem(color = Green500, label = "Disk") + } + + Spacer(modifier = Modifier.height(8.dp)) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) { + val chartWidth = size.width + val chartHeight = size.height + val padding = 40f + val drawWidth = chartWidth - padding * 2 + val drawHeight = chartHeight - padding * 2 + + val cpuValues = points.map { it.cpu_usage ?: 0.0 } + val ramValues = points.map { it.ram_usage ?: 0.0 } + val diskValues = points.map { it.disk_usage ?: 0.0 } + val maxVal = 100.0 + + fun toX(index: Int): Float { + if (points.size <= 1) return padding + return padding + (index.toFloat() / (points.size - 1).coerceAtLeast(1)) * drawWidth + } + + fun toY(value: Double): Float { + return (padding + drawHeight - ((value / maxVal) * drawHeight)).toFloat() + } + + drawLine( + color = Color.LightGray.copy(alpha = 0.5f), + start = Offset(padding, padding), + end = Offset(padding, padding + drawHeight), + strokeWidth = 1f + ) + drawLine( + color = Color.LightGray.copy(alpha = 0.5f), + start = Offset(padding, padding + drawHeight), + end = Offset(padding + drawWidth, padding + drawHeight), + strokeWidth = 1f + ) + + val gridSteps = 4 + for (i in 0..gridSteps) { + val y = padding + (drawHeight / gridSteps) * i + drawLine( + color = Color.LightGray.copy(alpha = 0.3f), + start = Offset(padding, y), + end = Offset(padding + drawWidth, y), + strokeWidth = 1f + ) + val label = "${(maxVal - (maxVal / gridSteps) * i).toInt()}%" + drawContext.canvas.nativeCanvas.drawText( + label, + 4f, + y + 4f, + android.graphics.Paint().apply { + color = android.graphics.Color.GRAY + textSize = 22f + } + ) + } + + fun drawMetricLine(values: List, color: Color) { + if (points.size < 2) return + val path = Path() + val count = (points.size * animationProgress).toInt().coerceIn(2, points.size) + + for (i in 0 until count) { + val x = toX(i) + val y = toY(values[i]) + if (i == 0) path.moveTo(x, y) + else path.lineTo(x, y) + } + + drawPath( + path = path, + color = color, + style = Stroke( + width = 3f, + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + + drawCircle(color, 5f, Offset(toX(count - 1), toY(values[count - 1]))) + } + + drawMetricLine(cpuValues, Orange500) + drawMetricLine(ramValues, Blue700) + drawMetricLine(diskValues, Green500) + } + } +} + +@Composable +private fun LegendItem(color: Color, label: String) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Canvas(modifier = Modifier.size(10.dp)) { + drawCircle(color = color) + } + Spacer(modifier = Modifier.width(4.dp)) + Text(text = label, style = MaterialTheme.typography.labelSmall, color = Grey500) + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/components/ServerCard.kt b/android/app/src/main/java/com/devlab/app/ui/components/ServerCard.kt new file mode 100644 index 0000000..28090d4 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/components/ServerCard.kt @@ -0,0 +1,160 @@ +package com.devlab.app.ui.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.devlab.app.data.model.Server +import com.devlab.app.ui.theme.* + +@Composable +fun ServerCard( + server: Server, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val statusColor = when (server.status) { + "online" -> Green500 + "offline" -> Red500 + "disabled" -> Grey500 + else -> Orange500 + } + val statusText = server.status.replaceFirstChar { it.uppercase() } + + val infiniteTransition = rememberInfiniteTransition(label = "status") + val pulseAlpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.8f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulse" + ) + + var pressed by remember { mutableStateOf(false) } + val elevation by animateDpAsState( + targetValue = if (pressed) 8.dp else 2.dp, + animationSpec = spring(dampingRatio = 0.5f, stiffness = 500f), + label = "elevation" + ) + + Card( + onClick = { + pressed = true + onClick() + }, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = elevation), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background( + if (server.status == "online") statusColor.copy(alpha = pulseAlpha) + else statusColor + ) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = server.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + Surface( + color = statusColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = statusText, + color = statusColor, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "${server.ip_address}:${server.ssh_port}", + style = MaterialTheme.typography.bodyMedium, + color = Grey700 + ) + + if (server.group_name != null) { + Text( + text = server.group_name, + style = MaterialTheme.typography.labelSmall, + color = Grey500 + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + MetricProgress("CPU", server.cpu_usage, Orange500) + MetricProgress("RAM", server.ram_usage, Blue700) + MetricProgress("Disk", server.disk_usage, Green500) + } + } + } +} + +@Composable +private fun MetricProgress(label: String, value: Double?, color: Color) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(72.dp) + ) { + Text( + text = if (value != null) "${value.toInt()}%" else "N/A", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { ((value ?: 0.0) / 100.0).toFloat() }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = color, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = Grey500 + ) + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/components/StatCard.kt b/android/app/src/main/java/com/devlab/app/ui/components/StatCard.kt new file mode 100644 index 0000000..99bc89e --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/components/StatCard.kt @@ -0,0 +1,67 @@ +package com.devlab.app.ui.components + +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun StatCard( + title: String, + value: String, + color: Color, + icon: ImageVector? = null, + modifier: Modifier = Modifier +) { + val numStr = value.filter { it.isDigit() } + val numericValue = numStr.toIntOrNull() ?: 0 + val animatedCount by animateIntAsState( + targetValue = numericValue, + animationSpec = tween(800, delayMillis = 100), + label = "statCount" + ) + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + Text( + text = if (numStr.isNotEmpty()) "${animatedCount}${if (value.endsWith("%")) "%" else ""}" else value, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = color + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} 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 new file mode 100644 index 0000000..784f707 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt @@ -0,0 +1,191 @@ +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 +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.devlab.app.ui.components.LoadingIndicator +import com.devlab.app.ui.components.StatCard +import com.devlab.app.ui.theme.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + onLogout: () -> Unit, + onProfileClick: () -> Unit = {}, + viewModel: DashboardViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Dashboard") + if (state.username.isNotBlank()) { + Text( + text = "Welcome, ${state.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ), + actions = { + IconButton(onClick = { onProfileClick() }) { + Icon( + Icons.Default.AccountCircle, + contentDescription = "Profile", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + IconButton(onClick = { viewModel.loadStats() }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + ) + } + ) { padding -> + when { + state.isLoading -> { + LoadingIndicator(modifier = Modifier.padding(padding)) + } + state.error != null -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + 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, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton(onClick = { viewModel.loadStats() }) { + Text("Retry") + } + } + } + } + else -> { + val dashboardStats = state.stats + + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .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 = "Servers", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + title = "Total", + value = "${dashboardStats?.stats?.total_servers ?: 0}", + color = MaterialTheme.colorScheme.primary, + icon = Icons.Default.Dns, + modifier = Modifier.weight(1f) + ) + StatCard( + title = "Online", + value = "${dashboardStats?.stats?.online_servers ?: 0}", + color = Green500, + icon = Icons.Default.CheckCircle, + modifier = Modifier.weight(1f) + ) + StatCard( + title = "Offline", + value = "${dashboardStats?.stats?.offline_servers ?: 0}", + color = Red500, + icon = Icons.Default.Cancel, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Average Usage", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + title = "CPU", + value = "${dashboardStats?.stats?.avg_cpu?.toInt() ?: 0}%", + color = Orange500, + icon = Icons.Default.Memory, + modifier = Modifier.weight(1f) + ) + StatCard( + title = "RAM", + value = "${dashboardStats?.stats?.avg_ram?.toInt() ?: 0}%", + color = Blue700, + icon = Icons.Default.Storage, + modifier = Modifier.weight(1f) + ) + StatCard( + title = "Disk", + value = "${dashboardStats?.stats?.avg_disk?.toInt() ?: 0}%", + color = Green500, + icon = Icons.Default.SdStorage, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..837c374 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardViewModel.kt @@ -0,0 +1,64 @@ +package com.devlab.app.ui.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devlab.app.ServerManagerApp +import com.devlab.app.data.model.DashboardStats +import com.devlab.app.data.repository.DashboardRepository +import com.devlab.app.util.PreferencesManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class DashboardUiState( + val isLoading: Boolean = true, + val stats: DashboardStats? = null, + val error: String? = null, + val username: String = "" +) + +class DashboardViewModel : ViewModel() { + + private val repository = DashboardRepository() + private val prefs = PreferencesManager(ServerManagerApp.instance) + + private val _uiState = MutableStateFlow(DashboardUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val savedUsername = prefs.getUsername() + _uiState.value = _uiState.value.copy(username = savedUsername) + } + loadStats() + } + + fun loadStats() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + try { + val response = repository.getStatus() + if (response.success) { + _uiState.value = DashboardUiState( + isLoading = false, + stats = response.data, + username = _uiState.value.username + ) + } else { + _uiState.value = DashboardUiState( + isLoading = false, + error = response.error ?: "Failed to load stats", + username = _uiState.value.username + ) + } + } catch (e: Exception) { + _uiState.value = DashboardUiState( + isLoading = false, + error = e.message ?: "Network error", + username = _uiState.value.username + ) + } + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/login/LoginScreen.kt b/android/app/src/main/java/com/devlab/app/ui/login/LoginScreen.kt new file mode 100644 index 0000000..70b92ce --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/login/LoginScreen.kt @@ -0,0 +1,224 @@ +package com.devlab.app.ui.login + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun LoginScreen( + onConnected: () -> Unit, + onRegisterClick: () -> Unit = {}, + viewModel: LoginViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var passwordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(state.isConnected) { + if (state.isConnected) onConnected() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), + MaterialTheme.colorScheme.surface + ), + startY = 0f, + endY = 600f + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.height(48.dp)) + + Surface( + modifier = Modifier.size(88.dp), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Icon( + imageVector = Icons.Default.Dns, + contentDescription = null, + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "ServerManager", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Sign in to manage your servers", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "Welcome back", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(20.dp)) + + OutlinedTextField( + value = state.username, + onValueChange = viewModel::updateUsername, + label = { Text("Username") }, + placeholder = { Text("Enter your username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon(Icons.Default.Person, contentDescription = null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + enabled = !state.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.password, + onValueChange = viewModel::updatePassword, + label = { Text("Password") }, + placeholder = { Text("Enter your password") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + visualTransformation = if (passwordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.Visibility + else Icons.Default.VisibilityOff, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { viewModel.login() }), + enabled = !state.isLoading + ) + + if (state.error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = state.error ?: "", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(12.dp) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { viewModel.login() }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + enabled = !state.isLoading + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Default.Login, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Sign In", fontWeight = FontWeight.SemiBold) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + TextButton(onClick = onRegisterClick) { + Text( + text = "Don't have an account? Register", + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer(modifier = Modifier.height(48.dp)) + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..49628f9 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt @@ -0,0 +1,96 @@ +package com.devlab.app.ui.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devlab.app.ServerManagerApp +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.repository.AuthRepository +import com.devlab.app.util.PreferencesManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class LoginUiState( + val username: String = "", + val password: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isConnected: Boolean = false +) + +class LoginViewModel : ViewModel() { + + private val repository = AuthRepository() + private val prefs = PreferencesManager(ServerManagerApp.instance) + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val savedToken = prefs.getToken() + if (savedToken.isNotBlank()) { + val savedUsername = prefs.getUsername() + RetrofitClient.setToken(savedToken) + _uiState.value = _uiState.value.copy( + isConnected = true, + username = savedUsername + ) + } + } + } + + fun updateUsername(value: String) { + _uiState.value = _uiState.value.copy(username = value, error = null) + } + + fun updatePassword(value: String) { + _uiState.value = _uiState.value.copy(password = value, error = null) + } + + fun login() { + val state = _uiState.value + if (state.username.isBlank()) { + _uiState.value = state.copy(error = "Please enter your username") + return + } + if (state.password.isBlank()) { + _uiState.value = state.copy(error = "Please enter your password") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + val result = repository.login(state.username, state.password) + result.fold( + onSuccess = { loginResult -> + RetrofitClient.setToken(loginResult.token) + prefs.saveUserData( + loginResult.token, loginResult.userId, + loginResult.username, loginResult.email, loginResult.role + ) + _uiState.value = _uiState.value.copy( + isLoading = false, + isConnected = true, + error = null + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message ?: "Login failed" + ) + } + ) + } + } + + fun logout() { + viewModelScope.launch { + prefs.clear() + RetrofitClient.setToken("") + _uiState.value = LoginUiState() + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/profile/ProfileScreen.kt b/android/app/src/main/java/com/devlab/app/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..404b320 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/profile/ProfileScreen.kt @@ -0,0 +1,304 @@ +package com.devlab.app.ui.profile + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + onBack: () -> Unit, + viewModel: ProfileViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var currentPasswordVisible by remember { mutableStateOf(false) } + var newPasswordVisible by remember { mutableStateOf(false) } + var confirmPasswordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { viewModel.loadProfile() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Profile") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.username, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(4.dp)) + + AssistChip( + onClick = {}, + label = { Text(state.role.replaceFirstChar { it.uppercase() }) }, + leadingIcon = { + Icon( + Icons.Default.Shield, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Account Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.username, + onValueChange = {}, + label = { Text("Username") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, + enabled = false, + singleLine = true + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.email, + onValueChange = viewModel::updateEmail, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + singleLine = true, + enabled = !state.isSaving, + isError = state.emailError != null + ) + if (state.emailError != null) { + Text( + text = state.emailError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Change Password", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.currentPassword, + onValueChange = viewModel::updateCurrentPassword, + label = { Text("Current Password") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = if (currentPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { currentPasswordVisible = !currentPasswordVisible }) { + Icon( + if (currentPasswordVisible) Icons.Default.Visibility + else Icons.Default.VisibilityOff, + contentDescription = null + ) + } + }, + singleLine = true, + enabled = !state.isSaving + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.newPassword, + onValueChange = viewModel::updateNewPassword, + label = { Text("New Password") }, + placeholder = { Text("Leave blank to keep current") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = if (newPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) { + Icon( + if (newPasswordVisible) Icons.Default.Visibility + else Icons.Default.VisibilityOff, + contentDescription = null + ) + } + }, + singleLine = true, + enabled = !state.isSaving, + isError = state.newPasswordError != null + ) + if (state.newPasswordError != null) { + Text( + text = state.newPasswordError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.confirmPassword, + onValueChange = viewModel::updateConfirmPassword, + label = { Text("Confirm New Password") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = if (confirmPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) { + Icon( + if (confirmPasswordVisible) Icons.Default.Visibility + else Icons.Default.VisibilityOff, + contentDescription = null + ) + } + }, + singleLine = true, + enabled = !state.isSaving, + isError = state.confirmPasswordError != null + ) + if (state.confirmPasswordError != null) { + Text( + text = state.confirmPasswordError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + } + } + + if (state.error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = state.error ?: "", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(12.dp) + ) + } + } + + if (state.successMessage != null) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Text( + text = state.successMessage ?: "", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(12.dp) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { viewModel.save() }, + modifier = Modifier.fillMaxWidth().height(50.dp), + enabled = !state.isSaving + ) { + if (state.isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Save Changes", fontWeight = FontWeight.SemiBold) + } + } + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/profile/ProfileViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..af6aac7 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/profile/ProfileViewModel.kt @@ -0,0 +1,128 @@ +package com.devlab.app.ui.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devlab.app.ServerManagerApp +import com.devlab.app.data.repository.ProfileRepository +import com.devlab.app.util.PreferencesManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ProfileUiState( + val isLoading: Boolean = true, + val username: String = "", + val email: String = "", + val role: String = "", + val currentPassword: String = "", + val newPassword: String = "", + val confirmPassword: String = "", + val isSaving: Boolean = false, + val error: String? = null, + val successMessage: String? = null, + val emailError: String? = null, + val newPasswordError: String? = null, + val confirmPasswordError: String? = null +) + +class ProfileViewModel : ViewModel() { + + private val repository = ProfileRepository() + private val prefs = PreferencesManager(ServerManagerApp.instance) + + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadProfile() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + val result = repository.getProfile() + result.fold( + onSuccess = { profile -> + _uiState.value = _uiState.value.copy( + isLoading = false, + username = profile.username, + email = profile.email, + role = profile.role + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message ?: "Failed to load profile" + ) + } + ) + } + } + + fun updateEmail(value: String) { + _uiState.value = _uiState.value.copy(email = value, error = null, successMessage = null, emailError = null) + } + + fun updateCurrentPassword(value: String) { + _uiState.value = _uiState.value.copy(currentPassword = value, error = null, successMessage = null) + } + + fun updateNewPassword(value: String) { + _uiState.value = _uiState.value.copy(newPassword = value, error = null, successMessage = null, newPasswordError = null) + } + + fun updateConfirmPassword(value: String) { + _uiState.value = _uiState.value.copy(confirmPassword = value, error = null, successMessage = null, confirmPasswordError = null) + } + + fun save() { + val state = _uiState.value + var hasError = false + + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(state.email).matches()) { + _uiState.value = _uiState.value.copy(emailError = "Enter a valid email address") + hasError = true + } + if (state.newPassword.isNotBlank()) { + if (state.newPassword.length < 8) { + _uiState.value = _uiState.value.copy(newPasswordError = "Min 8 characters") + hasError = true + } + if (state.confirmPassword != state.newPassword) { + _uiState.value = _uiState.value.copy(confirmPasswordError = "Passwords do not match") + hasError = true + } + if (state.currentPassword.isBlank()) { + _uiState.value = _uiState.value.copy(error = "Enter current password to change password") + hasError = true + } + } + if (hasError) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, error = null, successMessage = null) + val result = repository.updateProfile( + state.email, + state.currentPassword.takeIf { it.isNotBlank() }, + state.newPassword.takeIf { it.isNotBlank() }, + state.confirmPassword.takeIf { it.isNotBlank() } + ) + result.fold( + onSuccess = { message -> + prefs.saveProfile(state.email) + _uiState.value = _uiState.value.copy( + isSaving = false, + currentPassword = "", + newPassword = "", + confirmPassword = "", + successMessage = message + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isSaving = false, + error = e.message ?: "Update failed" + ) + } + ) + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/register/RegisterScreen.kt b/android/app/src/main/java/com/devlab/app/ui/register/RegisterScreen.kt new file mode 100644 index 0000000..002ef69 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/register/RegisterScreen.kt @@ -0,0 +1,226 @@ +package com.devlab.app.ui.register + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun RegisterScreen( + onRegistered: () -> Unit, + onLoginClick: () -> Unit, + viewModel: RegisterViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var passwordVisible by remember { mutableStateOf(false) } + var confirmPasswordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(state.isRegistered) { + if (state.isRegistered) onRegistered() + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Create Account", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Register to manage your servers", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = state.username, + onValueChange = viewModel::updateUsername, + label = { Text("Username") }, + placeholder = { Text("Choose a username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !state.isLoading, + isError = state.usernameError != null + ) + if (state.usernameError != null) { + Text( + text = state.usernameError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.email, + onValueChange = viewModel::updateEmail, + label = { Text("Email") }, + placeholder = { Text("Enter your email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + enabled = !state.isLoading, + isError = state.emailError != null + ) + if (state.emailError != null) { + Text( + text = state.emailError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.password, + onValueChange = viewModel::updatePassword, + label = { Text("Password") }, + placeholder = { Text("Min. 8 characters") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = if (passwordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.Visibility + else Icons.Default.VisibilityOff, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + enabled = !state.isLoading, + isError = state.passwordError != null + ) + if (state.passwordError != null) { + Text( + text = state.passwordError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.confirmPassword, + onValueChange = viewModel::updateConfirmPassword, + label = { Text("Confirm Password") }, + placeholder = { Text("Repeat your password") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = if (confirmPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) { + Icon( + imageVector = if (confirmPasswordVisible) Icons.Default.Visibility + else Icons.Default.VisibilityOff, + contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password" + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { viewModel.register() }), + enabled = !state.isLoading, + isError = state.confirmPasswordError != null + ) + if (state.confirmPasswordError != null) { + Text( + text = state.confirmPasswordError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp) + ) + } + + if (state.error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = state.error ?: "", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(12.dp) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { viewModel.register() }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + enabled = !state.isLoading + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text("Create Account", fontWeight = FontWeight.SemiBold) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = onLoginClick) { + Text("Already have an account? Sign In") + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/register/RegisterViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/register/RegisterViewModel.kt new file mode 100644 index 0000000..11937e8 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/register/RegisterViewModel.kt @@ -0,0 +1,101 @@ +package com.devlab.app.ui.register + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devlab.app.ServerManagerApp +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.repository.AuthRepository +import com.devlab.app.util.PreferencesManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class RegisterUiState( + val username: String = "", + val email: String = "", + val password: String = "", + val confirmPassword: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isRegistered: Boolean = false, + val usernameError: String? = null, + val emailError: String? = null, + val passwordError: String? = null, + val confirmPasswordError: String? = null +) + +class RegisterViewModel : ViewModel() { + + private val repository = AuthRepository() + private val prefs = PreferencesManager(ServerManagerApp.instance) + + private val _uiState = MutableStateFlow(RegisterUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateUsername(value: String) { + _uiState.value = _uiState.value.copy(username = value, error = null, usernameError = null) + } + + fun updateEmail(value: String) { + _uiState.value = _uiState.value.copy(email = value, error = null, emailError = null) + } + + fun updatePassword(value: String) { + _uiState.value = _uiState.value.copy(password = value, error = null, passwordError = null) + } + + fun updateConfirmPassword(value: String) { + _uiState.value = _uiState.value.copy(confirmPassword = value, error = null, confirmPasswordError = null) + } + + fun register() { + val state = _uiState.value + var hasError = false + + if (state.username.length < 3) { + _uiState.value = _uiState.value.copy(usernameError = "Username must be at least 3 characters") + hasError = true + } + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(state.email).matches()) { + _uiState.value = _uiState.value.copy(emailError = "Enter a valid email address") + hasError = true + } + if (state.password.length < 8) { + _uiState.value = _uiState.value.copy(passwordError = "Password must be at least 8 characters") + hasError = true + } + if (state.confirmPassword != state.password) { + _uiState.value = _uiState.value.copy(confirmPasswordError = "Passwords do not match") + hasError = true + } + if (hasError) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + val result = repository.register( + state.username, state.email, + state.password, state.confirmPassword + ) + result.fold( + onSuccess = { loginResult -> + RetrofitClient.setToken(loginResult.token) + prefs.saveUserData( + loginResult.token, loginResult.userId, + loginResult.username, loginResult.email, loginResult.role + ) + _uiState.value = _uiState.value.copy( + isLoading = false, + isRegistered = true + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message ?: "Registration failed" + ) + } + ) + } + } +} 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 new file mode 100644 index 0000000..6c34f50 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt @@ -0,0 +1,513 @@ +package com.devlab.app.ui.servers + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.devlab.app.ui.components.LoadingIndicator +import com.devlab.app.ui.components.MetricsChart +import com.devlab.app.ui.components.StatCard +import com.devlab.app.ui.theme.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerDetailScreen( + serverId: Int, + onBack: () -> Unit, + viewModel: ServerDetailViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(serverId) { + viewModel.load(serverId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(state.server?.name ?: "Server Detail") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + ) { padding -> + when { + state.isLoading -> LoadingIndicator(modifier = Modifier.padding(padding)) + state.error != null -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + 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 + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton(onClick = { viewModel.load(serverId) }) { + Text("Retry") + } + } + } + } + else -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + TabRow( + selectedTabIndex = state.selectedTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + Tab( + selected = state.selectedTab == 0, + onClick = { viewModel.selectTab(0) }, + text = { Text("Info") }, + icon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp)) } + ) + Tab( + selected = state.selectedTab == 1, + onClick = { viewModel.selectTab(1) }, + text = { Text("Metrics") }, + icon = { Icon(Icons.Default.StackedLineChart, contentDescription = null, modifier = Modifier.size(18.dp)) } + ) + Tab( + selected = state.selectedTab == 2, + onClick = { viewModel.selectTab(2) }, + text = { Text("Console") }, + icon = { Icon(Icons.Default.Terminal, contentDescription = null, modifier = Modifier.size(18.dp)) } + ) + } + + when (state.selectedTab) { + 0 -> InfoTab(state) + 1 -> MetricsTab(state) + 2 -> ConsoleTab(state, viewModel) + } + } + } + } + } +} + +@Composable +private fun InfoTab(state: ServerDetailUiState) { + val server = state.server ?: return + val metrics = state.metrics + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + val statusColor = when (server.status) { + "online" -> Green500 + "offline" -> Red500 + "disabled" -> Grey500 + else -> Orange500 + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + 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 + ) + } + + 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 + ) + } + } + } + + 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) + if (server.description != null) InfoRow("Description", server.description) + if (server.created_by_name != null) InfoRow("Created By", server.created_by_name) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (metrics != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + 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 + ) + } + + 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) + ) + } + + if (metrics.uptime != null) { + Spacer(modifier = Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Schedule, contentDescription = null, tint = Grey500, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Uptime: ${metrics.uptime}", style = MaterialTheme.typography.bodySmall, color = Grey700) + } + } + if (metrics.load_average != null) { + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.TrendingUp, contentDescription = null, tint = Grey500, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Load: ${metrics.load_average}", style = MaterialTheme.typography.bodySmall, color = Grey700) + } + } + } + } + } + } +} + +@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) + ) + } + HorizontalDivider(color = Grey100, thickness = 0.5.dp) +} + +@Composable +private fun MetricsTab(state: ServerDetailUiState) { + if (state.metricsHistory.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + 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 + ) + } + Spacer(modifier = Modifier.height(16.dp)) + 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 + ) + Spacer(modifier = Modifier.height(8.dp)) + + state.metricsHistory.takeLast(12).reversed().forEach { point -> + 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), + 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) + ) + } + } + } + } +} + +@Composable +private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + 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 + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + 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) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + 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 + ) + } else { + 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) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + 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 + ) + } + Spacer(modifier = Modifier.height(8.dp)) + 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( + 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 + ) + } + } + } + } +} + + 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 new file mode 100644 index 0000000..a61d312 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt @@ -0,0 +1,116 @@ +package com.devlab.app.ui.servers + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devlab.app.data.model.* +import com.devlab.app.data.repository.ServerRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ServerDetailUiState( + val isLoading: Boolean = true, + val server: Server? = null, + val metrics: LatestMetrics? = null, + val metricsHistory: List = emptyList(), + val error: String? = null, + val commandText: String = "", + val commandOutput: String? = null, + val commandError: String? = null, + val isExecuting: Boolean = false, + val selectedTab: Int = 0 +) + +class ServerDetailViewModel : ViewModel() { + + private val repository = ServerRepository() + private var serverId: Int = 0 + + private val _uiState = MutableStateFlow(ServerDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun load(id: Int) { + serverId = id + loadDetail() + } + + private fun loadDetail() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + try { + val response = repository.getServerDetail(serverId) + if (response.success) { + _uiState.value = _uiState.value.copy( + isLoading = false, + server = response.data?.server, + metrics = response.data?.metrics, + error = null + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = response.error ?: "Failed to load server details" + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message ?: "Network error" + ) + } + } + } + + fun loadMetrics() { + viewModelScope.launch { + try { + val response = repository.getServerMetrics(serverId) + if (response.success) { + _uiState.value = _uiState.value.copy( + metricsHistory = response.data ?: emptyList() + ) + } + } catch (_: Exception) { } + } + } + + fun updateCommand(text: String) { + _uiState.value = _uiState.value.copy(commandText = text, commandOutput = null, commandError = null) + } + + fun executeCommand() { + val command = _uiState.value.commandText.trim() + if (command.isBlank()) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isExecuting = true, commandOutput = null, commandError = null) + try { + val response = repository.executeCommand(serverId, command) + if (response.success) { + _uiState.value = _uiState.value.copy( + isExecuting = false, + commandOutput = response.data?.output ?: "(no output)" + ) + } else { + _uiState.value = _uiState.value.copy( + isExecuting = false, + commandError = response.error ?: "Command failed" + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isExecuting = false, + commandError = e.message ?: "Failed to execute command" + ) + } + } + } + + fun selectTab(tab: Int) { + _uiState.value = _uiState.value.copy(selectedTab = tab) + if (tab == 1 && _uiState.value.metricsHistory.isEmpty()) { + loadMetrics() + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/servers/ServerListScreen.kt b/android/app/src/main/java/com/devlab/app/ui/servers/ServerListScreen.kt new file mode 100644 index 0000000..673c54b --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerListScreen.kt @@ -0,0 +1,137 @@ +package com.devlab.app.ui.servers + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.devlab.app.ui.components.LoadingIndicator +import com.devlab.app.ui.components.ServerCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerListScreen( + onServerClick: (Int) -> Unit, + viewModel: ServerListViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LaunchedEffect(listState.canScrollForward) { + if (!listState.canScrollForward && !state.isLoadingMore && state.hasMore && state.servers.isNotEmpty()) { + viewModel.loadMore() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Servers") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ), + actions = { + IconButton(onClick = { viewModel.loadServers() }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = viewModel::updateSearch, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Search servers...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (state.searchQuery.isNotEmpty()) { + IconButton(onClick = { viewModel.updateSearch("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true + ) + + when { + state.isLoading -> LoadingIndicator() + state.error != null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.error ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton(onClick = { viewModel.loadServers() }) { + Text("Retry") + } + } + } + } + state.servers.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No servers found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + else -> { + LazyColumn( + state = listState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(state.servers, key = { it.id }) { server -> + ServerCard( + server = server, + onClick = { onServerClick(server.id) } + ) + } + + if (state.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/servers/ServerListViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/servers/ServerListViewModel.kt new file mode 100644 index 0000000..b07e353 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerListViewModel.kt @@ -0,0 +1,95 @@ +package com.devlab.app.ui.servers + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devlab.app.data.model.Server +import com.devlab.app.data.repository.ServerRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ServerListUiState( + val isLoading: Boolean = true, + val servers: List = emptyList(), + val error: String? = null, + val searchQuery: String = "", + val currentPage: Int = 1, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = true +) + +class ServerListViewModel : ViewModel() { + + private val repository = ServerRepository() + + private val _uiState = MutableStateFlow(ServerListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadServers() + } + + fun loadServers() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isLoading = true, + error = null, + currentPage = 1, + servers = emptyList() + ) + fetchServers(page = 1) + } + } + + fun loadMore() { + val state = _uiState.value + if (state.isLoadingMore || !state.hasMore) return + + viewModelScope.launch { + _uiState.value = state.copy(isLoadingMore = true) + fetchServers(page = state.currentPage + 1, append = true) + } + } + + fun updateSearch(query: String) { + _uiState.value = _uiState.value.copy(searchQuery = query) + loadServers() + } + + private suspend fun fetchServers(page: Int, append: Boolean = false) { + try { + val search = _uiState.value.searchQuery.takeIf { it.isNotBlank() } + val response = repository.getServers(page = page, search = search) + if (response.success) { + val newList = if (append) { + _uiState.value.servers + (response.data ?: emptyList()) + } else { + response.data ?: emptyList() + } + val pagination = response.pagination + val hasMore = pagination != null && page < pagination.total_pages + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoadingMore = false, + servers = newList, + currentPage = page, + hasMore = hasMore, + error = null + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoadingMore = false, + error = response.error ?: "Failed to load servers" + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoadingMore = false, + error = e.message ?: "Network error" + ) + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/theme/Color.kt b/android/app/src/main/java/com/devlab/app/ui/theme/Color.kt new file mode 100644 index 0000000..063471f --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/theme/Color.kt @@ -0,0 +1,29 @@ +package com.devlab.app.ui.theme + +import androidx.compose.ui.graphics.Color + +val Blue900 = Color(0xFF0D47A1) +val Blue700 = Color(0xFF1565C0) +val Blue500 = Color(0xFF2196F3) +val Cyan500 = Color(0xFF00BCD4) +val Green500 = Color(0xFF4CAF50) +val Red500 = Color(0xFFF44336) +val Orange500 = Color(0xFFFF9800) +val Grey50 = Color(0xFFF5F5F5) +val Grey100 = Color(0xFFE0E0E0) +val Grey300 = Color(0xFFBDBDBD) +val Grey500 = Color(0xFF9E9E9E) +val Grey700 = Color(0xFF616161) +val Grey900 = Color(0xFF212121) +val White = Color(0xFFFFFFFF) +val SurfaceLight = Color(0xFFFCFCFC) +val CardSurface = Color(0xFFFFFFFF) + +val Blue800 = Color(0xFF0D47A1) +val Blue600 = Color(0xFF1E88E5) +val Purple500 = Color(0xFF7C4DFF) + +val DarkSurface = Color(0xFF1A1C1E) +val DarkSurfaceVariant = Color(0xFF2A2C2E) +val DarkCardSurface = Color(0xFF252729) +val DarkBackground = Color(0xFF121416) diff --git a/android/app/src/main/java/com/devlab/app/ui/theme/Theme.kt b/android/app/src/main/java/com/devlab/app/ui/theme/Theme.kt new file mode 100644 index 0000000..34309c9 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/theme/Theme.kt @@ -0,0 +1,51 @@ +package com.devlab.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable + +private val LightColorScheme = lightColorScheme( + primary = Blue700, + onPrimary = White, + primaryContainer = Blue500, + secondary = Cyan500, + tertiary = Purple500, + background = Grey50, + surface = SurfaceLight, + surfaceVariant = Grey100, + error = Red500, + onBackground = Grey900, + onSurface = Grey900, + onSurfaceVariant = Grey700, + outline = Grey300 +) + +private val DarkColorScheme = darkColorScheme( + primary = Blue500, + onPrimary = White, + primaryContainer = Blue700, + secondary = Cyan500, + tertiary = Purple500, + background = DarkBackground, + surface = DarkSurface, + surfaceVariant = DarkSurfaceVariant, + error = Red500, + onBackground = White, + onSurface = White, + onSurfaceVariant = Grey300, + outline = Grey500 +) + +@Composable +fun ServerManagerTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/android/app/src/main/java/com/devlab/app/ui/theme/Type.kt b/android/app/src/main/java/com/devlab/app/ui/theme/Type.kt new file mode 100644 index 0000000..09a0eff --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/theme/Type.kt @@ -0,0 +1,49 @@ +package com.devlab.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 22.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp + ) +) diff --git a/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt new file mode 100644 index 0000000..067fe82 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt @@ -0,0 +1,74 @@ +package com.devlab.app.util + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.dataStore by preferencesDataStore(name = "servermanager_prefs") + +class PreferencesManager(private val context: Context) { + + companion object { + private val KEY_TOKEN = stringPreferencesKey("api_token") + private val KEY_USERNAME = stringPreferencesKey("username") + private val KEY_EMAIL = stringPreferencesKey("email") + private val KEY_ROLE = stringPreferencesKey("role") + private val KEY_USER_ID = intPreferencesKey("user_id") + } + + val token: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_TOKEN] ?: "" + } + + val username: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_USERNAME] ?: "" + } + + val email: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_EMAIL] ?: "" + } + + val role: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_ROLE] ?: "" + } + + val userId: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_USER_ID] ?: 0 + } + + suspend fun saveCredentials(token: String, username: String) { + context.dataStore.edit { prefs -> + prefs[KEY_TOKEN] = token + prefs[KEY_USERNAME] = username + } + } + + suspend fun saveUserData(token: String, id: Int, username: String, email: String, role: String) { + context.dataStore.edit { prefs -> + prefs[KEY_TOKEN] = token + prefs[KEY_USER_ID] = id + prefs[KEY_USERNAME] = username + prefs[KEY_EMAIL] = email + prefs[KEY_ROLE] = role + } + } + + suspend fun saveProfile(email: String) { + context.dataStore.edit { prefs -> + prefs[KEY_EMAIL] = email + } + } + + suspend fun getToken(): String = context.dataStore.data.first()[KEY_TOKEN] ?: "" + suspend fun getUsername(): String = context.dataStore.data.first()[KEY_USERNAME] ?: "" + suspend fun getEmail(): String = context.dataStore.data.first()[KEY_EMAIL] ?: "" + + suspend fun clear() { + context.dataStore.edit { it.clear() } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..8383e44 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f8ad4d6 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8c0deb4 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,14 @@ + + + #1565C0 + #0D47A1 + #00BCD4 + #F5F5F5 + #FFFFFF + #D32F2F + #FFFFFF + #4CAF50 + #F44336 + #FF9800 + #1565C0 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..856d322 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + ServerManager + Server Management Platform + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..cd83e41 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..c0d2a26 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "9.2.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5dd3c01 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..da3ba2f --- /dev/null +++ b/android/gradlew @@ -0,0 +1,154 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced functionality shells and target +# temporary focusing, current function. +# +# (2) You need a Java installation to run Gradle. If JAVA_HOME is set, it +# will be used. Otherwise, java from PATH will be used. +# +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in + '' | soft) :;; + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://processed items. +# shellcheck disable=SC2153 +case $TERM in + dumb | '' ) : ;; + * ) eval `resize 2>/dev/null` ;; +esac + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and/or backslashes, so put them in +# temporary files to avoid running into problems with process substitution. +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xeli" is not available. +if ! "$cygwin" && ! "$msys" && ! "$nonstop" ; then + exec "$JAVACMD" "$@" +fi + +exec "$JAVACMD" "$@" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..73ac270 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ServerManager" +include(":app") diff --git a/public/sysadmin.apk b/public/sysadmin.apk new file mode 100644 index 0000000..09c8658 Binary files /dev/null and b/public/sysadmin.apk differ diff --git a/routes/web.php b/routes/web.php index 5988f68..dc7785f 100755 --- a/routes/web.php +++ b/routes/web.php @@ -101,5 +101,9 @@ $router->put('/api/servers/:id', [ApiController::class, 'serverUpdate']); $router->delete('/api/servers/:id', [ApiController::class, 'serverDelete']); $router->get('/api/servers/:id/metrics', [ApiController::class, 'serverMetrics']); $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/users', [ApiController::class, 'users']); +$router->get('/api/profile', [ApiController::class, 'profile']); +$router->put('/api/profile', [ApiController::class, 'updateProfile']); $router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']); diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index a922b12..590f3bd 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -6,13 +6,13 @@ namespace ServerManager\Controllers; use ServerManager\Core\App; use ServerManager\Core\Session; -use ServerManager\Core\Validator; use ServerManager\Models\Server; use ServerManager\Models\User; use ServerManager\Services\MonitoringService; use ServerManager\Services\SSHService; use ServerManager\Services\AuditService; use ServerManager\Models\CommandHistory; +use ServerManager\Core\Validator; class ApiController { @@ -305,4 +305,219 @@ class ApiController 'data' => $results, ]); } + + public function register(): void + { + $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; + + $validator = new Validator(); + $rules = [ + 'username' => 'required|min:3|max:50', + 'email' => 'required|email', + 'password' => 'required|min:8', + 'confirm_password' => 'required', + ]; + + if (!$validator->validate($input, $rules)) { + $this->view->json([ + 'success' => false, + 'error' => $validator->getFirstError(), + ], 400); + return; + } + + if ($input['password'] !== $input['confirm_password']) { + $this->view->json([ + 'success' => false, + 'error' => 'Passwords do not match.', + ], 400); + return; + } + + $userModel = new User(); + + $existing = $userModel->findByUsername($input['username']); + if ($existing) { + $this->view->json([ + 'success' => false, + 'error' => 'Username is already taken.', + ], 409); + return; + } + + $existingEmail = $userModel->findByEmail($input['email']); + if ($existingEmail) { + $this->view->json([ + 'success' => false, + 'error' => 'Email is already registered.', + ], 409); + return; + } + + $userId = $userModel->create([ + 'username' => $input['username'], + 'email' => $input['email'], + 'password' => $input['password'], + 'role' => 'admin', + 'is_active' => 1, + ]); + + $user = $userModel->findById($userId); + + $auditService = new AuditService(); + $auditService->log('user_registered', 'User registered via API', $userId); + + $this->view->json([ + 'success' => true, + 'data' => [ + 'token' => $user['api_token'], + 'user' => [ + 'id' => (int) $user['id'], + 'username' => $user['username'], + 'email' => $user['email'], + 'role' => $user['role'], + ], + ], + ], 201); + } + + public function profile(): void + { + $user = $this->authenticateRequest(); + + $this->view->json([ + 'success' => true, + 'data' => [ + 'id' => (int) $user['id'], + 'username' => $user['username'], + 'email' => $user['email'], + 'role' => $user['role'], + 'created_at' => $user['created_at'], + ], + ]); + } + + public function updateProfile(): void + { + $user = $this->authenticateRequest(); + + $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; + + $validator = new Validator(); + $rules = [ + 'email' => 'required|email', + ]; + + if (!$validator->validate($input, $rules)) { + $this->view->json([ + 'success' => false, + 'error' => $validator->getFirstError(), + ], 400); + return; + } + + $updateData = ['email' => $input['email']]; + + if (!empty($input['new_password'])) { + if (strlen($input['new_password']) < 8) { + $this->view->json([ + 'success' => false, + 'error' => 'New password must be at least 8 characters.', + ], 400); + return; + } + if (empty($input['current_password'])) { + $this->view->json([ + 'success' => false, + 'error' => 'Current password is required to set a new password.', + ], 400); + return; + } + if ($input['new_password'] !== ($input['confirm_password'] ?? '')) { + $this->view->json([ + 'success' => false, + 'error' => 'New passwords do not match.', + ], 400); + return; + } + + $userModel = new User(); + $fullUser = $userModel->findById((int) $user['id']); + if (!$fullUser || !(new \ServerManager\Core\Security())->verifyPassword($input['current_password'], $fullUser['password_hash'])) { + $this->view->json([ + 'success' => false, + 'error' => 'Current password is incorrect.', + ], 403); + return; + } + + $updateData['password'] = $input['new_password']; + } + + $userModel = new User(); + $userModel->update((int) $user['id'], $updateData); + + $auditService = new AuditService(); + $auditService->log('profile_updated', 'Profile updated via API', (int) $user['id']); + + $this->view->json([ + 'success' => true, + 'message' => 'Profile updated successfully.', + ]); + } + + public function login(): void + { + $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; + + $validator = new Validator(); + $rules = [ + 'username' => 'required', + 'password' => 'required|min:6', + ]; + + if (!$validator->validate($input, $rules)) { + $this->view->json([ + 'success' => false, + 'error' => $validator->getFirstError(), + ], 400); + return; + } + + $userModel = new User(); + $user = $userModel->authenticate($input['username'], $input['password']); + + if (!$user) { + $auditService = new AuditService(); + $auditService->logLogin($input['username'], false, 'Invalid credentials'); + $this->view->json([ + 'success' => false, + 'error' => 'Invalid username or password.', + ], 401); + return; + } + + $token = $user['api_token']; + if (empty($token)) { + $security = new \ServerManager\Core\Security(); + $token = $security->generateApiToken(); + $userModel->update((int) $user['id'], ['api_token' => $token]); + } + + $auditService = new AuditService(); + $auditService->logLogin($input['username'], true); + + $this->view->json([ + 'success' => true, + 'data' => [ + 'token' => $token, + 'user' => [ + 'id' => (int) $user['id'], + 'username' => $user['username'], + 'email' => $user['email'], + 'role' => $user['role'], + ], + ], + ]); + } } diff --git a/views/dashboard/index.php b/views/dashboard/index.php index d1fed38..b236dd2 100755 --- a/views/dashboard/index.php +++ b/views/dashboard/index.php @@ -52,6 +52,13 @@ $recentActivity = $recentActivity ?? []; Avg Disk + + + + v1.0 + Android App + + diff --git a/views/layouts/main.php b/views/layouts/main.php index 4d29aeb..b5ee07e 100755 --- a/views/layouts/main.php +++ b/views/layouts/main.php @@ -76,6 +76,14 @@ + + Mobile + + + + Download APK + +