diff --git a/README.md b/README.md index ddee696..bf530c6 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,31 @@ ## Статус -**Версия:** `0.1.0` (документация и план; код приложения — в разработке) +**Версия:** `0.4.0` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM) + +## Документация + +| Документ | Назначение | +|----------|------------| +| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора | +| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки | +| [SAC: seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) | Админ: коды, устройства, сессия | +| [SAC: seaca-fcm.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-fcm.md) | FCM на сервере | + +## Сборка + +Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`). + +```bash +# Опционально: google-services.json в app/ (из Firebase, не в git) — для FCM +./gradlew assembleDebug +# Windows: +gradlew.bat assembleDebug +``` + +APK: `app/build/outputs/apk/debug/app-debug.apk` + +Без `google-services.json` приложение собирается, но FCM не инициализируется (просмотр данных работает). ## Лицензия diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d03ccd0 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "ru.kalinamall.seaca" + compileSdk = 35 + + defaultConfig { + applicationId = "ru.kalinamall.seaca" + minSdk = 26 + targetSdk = 35 + versionCode = 4 + versionName = "0.4.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.navigation:navigation-compose:2.8.3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("androidx.biometric:biometric:1.1.0") + + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.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.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0") + + implementation(platform("com.google.firebase:firebase-bom:33.5.1")) + implementation("com.google.firebase:firebase-messaging-ktx") + + debugImplementation("androidx.compose.ui:ui-tooling") +} + +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} diff --git a/app/google-services.json.example b/app/google-services.json.example new file mode 100644 index 0000000..9cce29e --- /dev/null +++ b/app/google-services.json.example @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "000000000000", + "project_id": "REPLACE_ME", + "storage_bucket": "REPLACE_ME.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "ru.kalinamall.seaca" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "REPLACE_ME" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..5e662e9 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Seaca — defaults sufficient for debug/release without minify diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..20b3375 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt new file mode 100644 index 0000000..fc96a87 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt @@ -0,0 +1,281 @@ +package ru.kalinamall.seaca + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf as composeMutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.push.FcmRegistrar +import ru.kalinamall.seaca.security.BiometricGate +import ru.kalinamall.seaca.ui.screens.ConnectScreen +import ru.kalinamall.seaca.ui.screens.DashboardScreen +import ru.kalinamall.seaca.ui.screens.DeviceScreen +import ru.kalinamall.seaca.ui.screens.EventDetailScreen +import ru.kalinamall.seaca.ui.screens.EventsScreen +import ru.kalinamall.seaca.ui.screens.HostDetailScreen +import ru.kalinamall.seaca.ui.screens.HostsScreen +import ru.kalinamall.seaca.ui.screens.ProblemDetailScreen +import ru.kalinamall.seaca.ui.screens.ProblemsScreen +import ru.kalinamall.seaca.ui.screens.ReportsScreen +import ru.kalinamall.seaca.ui.theme.SeacaTheme + +class MainActivity : FragmentActivity() { + private lateinit var repo: SacRepository + private var needsBiometric = false + private var biometricPassed = true + private val deepLinkState = composeMutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + repo = SacRepository(applicationContext) + requestNotificationPermission() + deepLinkState.value = extractDeepLink(intent) + setContent { + SeacaApp( + repo = repo, + deepLink = deepLinkState.value, + onDeepLinkConsumed = { deepLinkState.value = null }, + biometricUnlocked = biometricPassed || !needsBiometric, + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + deepLinkState.value = extractDeepLink(intent) + } + + override fun onStop() { + super.onStop() + if (repo.session.getAccessToken() != null) { + needsBiometric = true + biometricPassed = false + } + } + + override fun onResume() { + super.onResume() + if (needsBiometric && repo.session.getAccessToken() != null && !biometricPassed) { + BiometricGate.authenticate( + activity = this, + onSuccess = { + biometricPassed = true + needsBiometric = false + }, + onFailure = { finish() }, + ) + } + } + + private fun extractDeepLink(intent: Intent?): String? { + intent?.getStringExtra("deep_link")?.let { return it } + val uri: Uri = intent?.data ?: return null + val id = uri.lastPathSegment ?: return null + return when (uri.host) { + "event" -> "event/$id" + "problem" -> "problem/$id" + else -> null + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED + ) { + return + } + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001) + } +} + +@Composable +private fun SeacaApp( + repo: SacRepository, + deepLink: String?, + onDeepLinkConsumed: () -> Unit, + biometricUnlocked: Boolean, +) { + val context = LocalContext.current + val loggedIn by repo.session.isLoggedIn.collectAsStateWithLifecycle(initialValue = false) + val nav = rememberNavController() + val scope = rememberCoroutineScope() + LaunchedEffect(loggedIn) { + if (loggedIn) { + FcmRegistrar.registerIfPossible(context, repo) + } + } + + LaunchedEffect(deepLink, loggedIn, biometricUnlocked) { + if (!loggedIn || !biometricUnlocked || deepLink.isNullOrBlank()) return@LaunchedEffect + when { + deepLink.startsWith("event/") -> { + nav.navigate("event/${deepLink.removePrefix("event/")}") + onDeepLinkConsumed() + } + deepLink.startsWith("problem/") -> { + nav.navigate("problem/${deepLink.removePrefix("problem/")}") + onDeepLinkConsumed() + } + } + } + + val canOperate = remember(loggedIn) { + val role = repo.session.getRole() + role == "operator" || role == "admin" + } + + SeacaTheme { + if (!loggedIn) { + ConnectScreen( + repo = repo, + onConnected = { + scope.launch { FcmRegistrar.registerIfPossible(context, repo) } + }, + ) + return@SeacaTheme + } + + if (!biometricUnlocked) { + Text("Подтвердите доступ…") + return@SeacaTheme + } + + NavHost(navController = nav, startDestination = "main") { + composable("main") { + MainShell( + repo = repo, + onOpenEvent = { nav.navigate("event/$it") }, + onOpenProblem = { nav.navigate("problem/$it") }, + onOpenHost = { nav.navigate("host/$it") }, + onOpenDevice = { nav.navigate("device") }, + ) + } + composable( + "event/{id}", + arguments = listOf(navArgument("id") { type = NavType.LongType }), + ) { entry -> + EventDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() } + } + composable( + "problem/{id}", + arguments = listOf(navArgument("id") { type = NavType.LongType }), + ) { entry -> + ProblemDetailScreen( + repo = repo, + id = entry.arguments?.getLong("id") ?: 0L, + canOperate = canOperate, + onBack = { nav.popBackStack() }, + onChanged = { }, + ) + } + composable( + "host/{id}", + arguments = listOf(navArgument("id") { type = NavType.LongType }), + ) { entry -> + HostDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() } + } + composable("device") { + DeviceScreen(repo) { nav.popBackStack("main", false) } + } + } + } +} + +private enum class MainTab(val label: String) { + Dashboard("Обзор"), + Events("События"), + Problems("Проблемы"), + Hosts("Хосты"), + Reports("Отчёты"), +} + +@Composable +private fun MainShell( + repo: SacRepository, + onOpenEvent: (Long) -> Unit, + onOpenProblem: (Long) -> Unit, + onOpenHost: (Long) -> Unit, + onOpenDevice: () -> Unit, +) { + var tab by remember { mutableStateOf(MainTab.Dashboard) } + + Scaffold( + bottomBar = { + NavigationBar { + MainTab.entries.forEach { item -> + NavigationBarItem( + selected = tab == item, + onClick = { tab = item }, + icon = { + Icon( + when (item) { + MainTab.Dashboard -> Icons.Default.Dashboard + MainTab.Events -> Icons.Default.Warning + MainTab.Problems -> Icons.Default.Error + MainTab.Hosts -> Icons.Default.Computer + MainTab.Reports -> Icons.Default.Description + }, + contentDescription = item.label, + ) + }, + label = { Text(item.label) }, + ) + } + NavigationBarItem( + selected = false, + onClick = onOpenDevice, + icon = { Icon(Icons.Default.PhoneAndroid, contentDescription = "Устройство") }, + label = { Text("Устройство") }, + ) + } + }, + ) { padding -> + Box(Modifier.padding(padding)) { + when (tab) { + MainTab.Dashboard -> DashboardScreen(repo) + MainTab.Events -> EventsScreen(repo, onOpenEvent) + MainTab.Problems -> ProblemsScreen(repo, onOpenProblem) + MainTab.Hosts -> HostsScreen(repo, onOpenHost) + MainTab.Reports -> ReportsScreen(repo, onOpenEvent) + } + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt new file mode 100644 index 0000000..1f9c7b4 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt @@ -0,0 +1,11 @@ +package ru.kalinamall.seaca + +import android.app.Application +import ru.kalinamall.seaca.push.NotificationHelper + +class SeacaApplication : Application() { + override fun onCreate() { + super.onCreate() + NotificationHelper.ensureChannels(this) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt new file mode 100644 index 0000000..871f9d7 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt @@ -0,0 +1,66 @@ +package ru.kalinamall.seaca.data + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface SacApi { + @GET("health") + suspend fun health(): HealthResponse + + @POST("api/v1/mobile/enroll") + suspend fun enroll(@Body body: EnrollRequest): AuthResponse + + @POST("api/v1/mobile/auth/refresh") + suspend fun refresh(@Body body: RefreshRequest): AuthResponse + + @PUT("api/v1/mobile/devices/me/fcm") + suspend fun updateFcmToken(@Body body: FcmTokenRequest) + + @GET("api/v1/dashboards/summary") + suspend fun dashboardSummary(): DashboardSummary + + @GET("api/v1/events") + suspend fun listEvents( + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 30, + @Query("severity") severity: String? = null, + @Query("type") type: String? = null, + @Query("hostname") hostname: String? = null, + @Query("q") q: String? = null, + ): EventListResponse + + @GET("api/v1/events/{id}") + suspend fun getEvent(@Path("id") id: Long): EventDetail + + @GET("api/v1/problems") + suspend fun listProblems( + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 30, + @Query("status") status: String? = null, + @Query("severity") severity: String? = null, + @Query("hostname") hostname: String? = null, + ): ProblemListResponse + + @GET("api/v1/problems/{id}") + suspend fun getProblem(@Path("id") id: Long): ProblemDetail + + @POST("api/v1/problems/{id}/ack") + suspend fun ackProblem(@Path("id") id: Long): ProblemActionResponse + + @POST("api/v1/problems/{id}/resolve") + suspend fun resolveProblem(@Path("id") id: Long): ProblemActionResponse + + @GET("api/v1/hosts") + suspend fun listHosts( + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 30, + @Query("hostname") hostname: String? = null, + ): HostListResponse + + @GET("api/v1/hosts/{id}") + suspend fun getHost(@Path("id") id: Long): HostDetail +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt new file mode 100644 index 0000000..793a3f0 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt @@ -0,0 +1,198 @@ +package ru.kalinamall.seaca.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class HealthResponse( + val status: String = "", + val database: String? = null, + val version: String? = null, +) + +@Serializable +data class EnrollRequest( + @SerialName("enrollment_code") val enrollmentCode: String, + val username: String? = null, + val password: String? = null, + @SerialName("device_uuid") val deviceUuid: String, + @SerialName("display_name") val displayName: String = "Android", + val platform: String = "android", + @SerialName("app_version") val appVersion: String, + @SerialName("fcm_token") val fcmToken: String? = null, +) + +@Serializable +data class RefreshRequest( + @SerialName("refresh_token") val refreshToken: String, + @SerialName("device_uuid") val deviceUuid: String, +) + +@Serializable +data class AuthResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String, + @SerialName("token_type") val tokenType: String = "bearer", + @SerialName("device_id") val deviceId: Int, + val username: String, + val role: String, +) + +@Serializable +data class FcmTokenRequest( + @SerialName("fcm_token") val fcmToken: String, +) + +@Serializable +data class EventSummary( + val id: Long, + @SerialName("event_id") val eventId: String, + @SerialName("host_id") val hostId: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("occurred_at") val occurredAt: String, + @SerialName("received_at") val receivedAt: String? = null, + val type: String, + val severity: String, + val title: String, + val summary: String, + @SerialName("actor_user") val actorUser: String? = null, +) + +@Serializable +data class EventListResponse( + val items: List, + val total: Int, + val page: Int, + @SerialName("page_size") val pageSize: Int, +) + +@Serializable +data class EventDetail( + val id: Long, + @SerialName("event_id") val eventId: String, + @SerialName("host_id") val hostId: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("occurred_at") val occurredAt: String, + val type: String, + val severity: String, + val title: String, + val summary: String, + val details: JsonElement? = null, + val raw: JsonElement? = null, + val payload: JsonElement? = null, +) + +@Serializable +data class ProblemSummary( + val id: Long, + @SerialName("host_id") val hostId: Long? = null, + val hostname: String? = null, + val title: String, + val summary: String, + val severity: String, + val status: String, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String? = null, +) + +@Serializable +data class ProblemListResponse( + val items: List, + val total: Int, + val page: Int, + @SerialName("page_size") val pageSize: Int, +) + +@Serializable +data class ProblemEventItem( + val id: Long, + @SerialName("event_id") val eventId: String, + @SerialName("occurred_at") val occurredAt: String, + val type: String, + val severity: String, + val title: String, + val summary: String, +) + +@Serializable +data class ProblemDetail( + val id: Long, + val title: String, + val summary: String, + val severity: String, + val status: String, + val hostname: String? = null, + val events: List = emptyList(), +) + +@Serializable +data class HostSummary( + val id: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("os_family") val osFamily: String, + val product: String, + @SerialName("agent_status") val agentStatus: String, + @SerialName("last_seen_at") val lastSeenAt: String, +) + +@Serializable +data class HostListResponse( + val items: List, + val total: Int, + val page: Int, + @SerialName("page_size") val pageSize: Int, +) + +@Serializable +data class HostDetail( + val id: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("os_family") val osFamily: String, + @SerialName("os_version") val osVersion: String? = null, + val product: String, + @SerialName("product_version") val productVersion: String? = null, + @SerialName("agent_status") val agentStatus: String, + @SerialName("last_seen_at") val lastSeenAt: String, + val inventory: JsonElement? = null, +) + +@Serializable +data class TopHostItem( + @SerialName("host_id") val hostId: Long, + val hostname: String, + val count: Int, +) + +@Serializable +data class TopTypeItem( + val type: String, + val count: Int, +) + +@Serializable +data class DashboardSummary( + @SerialName("events_last_24h") val eventsLast24h: Int, + @SerialName("hosts_total") val hostsTotal: Int, + @SerialName("hosts_stale") val hostsStale: Int, + @SerialName("problems_open") val problemsOpen: Int, + @SerialName("severity_24h") val severity24h: Map = emptyMap(), + @SerialName("top_hosts") val topHosts: List = emptyList(), + @SerialName("top_event_types") val topEventTypes: List = emptyList(), + @SerialName("recent_events") val recentEvents: List = emptyList(), +) + +@Serializable +data class ProblemActionResponse( + val id: Long, + val status: String, +) + +@Serializable +data class ApiErrorBody( + val detail: String? = null, +) diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt new file mode 100644 index 0000000..2a4347a --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt @@ -0,0 +1,152 @@ +package ru.kalinamall.seaca.data + +import android.content.Context +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import ru.kalinamall.seaca.BuildConfig +import ru.kalinamall.seaca.security.TlsTrust +import java.util.concurrent.TimeUnit + +class SacRepository(context: Context) { + val session = SessionStore(context.applicationContext) + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + @Volatile + private var api: SacApi? = null + + @Volatile + private var baseUrl: String? = null + + suspend fun ensureClient(): SacApi { + val url = session.getBaseUrl() ?: error("Не задан URL сервера") + if (api == null || baseUrl != url) { + baseUrl = url + api = buildApi(url, session.trustAllCerts()) + } + return api!! + } + + suspend fun checkHealth(serverUrl: String, trustAll: Boolean): HealthResponse { + val normalized = normalizeUrl(serverUrl) + return buildApi(normalized, trustAll).health() + } + + suspend fun enroll( + serverUrl: String, + enrollmentCode: String, + username: String?, + password: String?, + displayName: String, + fcmToken: String?, + trustAll: Boolean, + ): AuthResponse { + val normalized = normalizeUrl(serverUrl) + val deviceUuid = session.getDeviceUuid() + val response = buildApi(normalized, trustAll).enroll( + EnrollRequest( + enrollmentCode = enrollmentCode.trim(), + username = username?.trim()?.ifBlank { null }, + password = password?.ifBlank { null }, + deviceUuid = deviceUuid, + displayName = displayName, + appVersion = BuildConfig.VERSION_NAME, + fcmToken = fcmToken, + ), + ) + session.saveSession(normalized, response) + if (trustAll) session.setTrustAllCerts(true) + api = buildApi(normalized, trustAll) + baseUrl = normalized + return response + } + + suspend fun withAuth(block: suspend (SacApi) -> T): T { + val client = ensureClient() + return try { + block(client) + } catch (e: retrofit2.HttpException) { + if (e.code() == 401) { + refreshSession() + block(ensureClient()) + } else { + throw e + } + } + } + + private suspend fun refreshSession() { + val refresh = session.getRefreshToken() ?: throw IllegalStateException("Сессия истекла") + val uuid = session.getDeviceUuid() + val url = session.getBaseUrl() ?: error("Нет URL") + val response = buildApi(url, session.trustAllCerts()).refresh( + RefreshRequest(refreshToken = refresh, deviceUuid = uuid), + ) + session.updateTokens(response) + api = buildApi(url, session.trustAllCerts()) + } + + suspend fun registerFcmToken(token: String) { + withAuth { it.updateFcmToken(FcmTokenRequest(token)) } + } + + suspend fun logout() { + api = null + baseUrl = null + session.clear() + } + + private fun buildApi(url: String, trustAll: Boolean): SacApi { + val authInterceptor = Interceptor { chain -> + val token = session.getAccessToken() + val request = if (token != null) { + chain.request().newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + chain.proceed(request) + } + val logging = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE + } + val okhttp = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll)) + .hostnameVerifier(TlsTrust.hostnameVerifier(trustAll)) + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .build() + return Retrofit.Builder() + .baseUrl(url.trimEnd('/') + "/") + .client(okhttp) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(SacApi::class.java) + } + + private fun normalizeUrl(raw: String): String { + var u = raw.trim() + if (!u.startsWith("http://") && !u.startsWith("https://")) { + u = "https://$u" + } + return u.removeSuffix("/") + } + + companion object { + fun parseError(e: retrofit2.HttpException): String { + val body = e.response()?.errorBody()?.string().orEmpty() + return try { + Json.decodeFromString(body).detail ?: body + } catch (_: Exception) { + body.ifBlank { "HTTP ${e.code()}" } + } + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt new file mode 100644 index 0000000..5dbde79 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt @@ -0,0 +1,99 @@ +package ru.kalinamall.seaca.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.util.UUID + +private val Context.dataStore by preferencesDataStore("seaca_prefs") + +class SessionStore(private val context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val securePrefs = EncryptedSharedPreferences.create( + context, + "seaca_secure", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + val isLoggedIn: Flow = context.dataStore.data.map { prefs -> + !prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS) + } + + suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL] + + suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false + + suspend fun setTrustAllCerts(value: Boolean) { + context.dataStore.edit { it[KEY_TRUST_ALL] = value } + } + + suspend fun getDeviceUuid(): String { + val existing = context.dataStore.data.first()[KEY_DEVICE_UUID] + if (!existing.isNullOrBlank()) return existing + val fresh = UUID.randomUUID().toString() + context.dataStore.edit { it[KEY_DEVICE_UUID] = fresh } + return fresh + } + + fun getAccessToken(): String? = securePrefs.getString(KEY_ACCESS, null) + + fun getRefreshToken(): String? = securePrefs.getString(KEY_REFRESH, null) + + fun getDeviceId(): Int = securePrefs.getInt(KEY_DEVICE_ID, 0) + + fun getUsername(): String? = securePrefs.getString(KEY_USERNAME, null) + + fun getRole(): String? = securePrefs.getString(KEY_ROLE, null) + + suspend fun saveSession(baseUrl: String, auth: AuthResponse) { + val normalized = baseUrl.trim().removeSuffix("/") + context.dataStore.edit { it[KEY_BASE_URL] = normalized } + securePrefs.edit() + .putString(KEY_ACCESS, auth.accessToken) + .putString(KEY_REFRESH, auth.refreshToken) + .putInt(KEY_DEVICE_ID, auth.deviceId) + .putString(KEY_USERNAME, auth.username) + .putString(KEY_ROLE, auth.role) + .apply() + } + + suspend fun updateTokens(auth: AuthResponse) { + securePrefs.edit() + .putString(KEY_ACCESS, auth.accessToken) + .putString(KEY_REFRESH, auth.refreshToken) + .putInt(KEY_DEVICE_ID, auth.deviceId) + .apply() + } + + suspend fun clear() { + context.dataStore.edit { + it.remove(KEY_BASE_URL) + it.remove(KEY_TRUST_ALL) + } + securePrefs.edit().clear().apply() + } + + companion object { + private val KEY_BASE_URL = stringPreferencesKey("base_url") + private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid") + private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs") + private const val KEY_ACCESS = "access_token" + private const val KEY_REFRESH = "refresh_token" + private const val KEY_DEVICE_ID = "device_id" + private const val KEY_USERNAME = "username" + private const val KEY_ROLE = "role" + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/FcmRegistrar.kt b/app/src/main/java/ru/kalinamall/seaca/push/FcmRegistrar.kt new file mode 100644 index 0000000..d3537e0 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/FcmRegistrar.kt @@ -0,0 +1,26 @@ +package ru.kalinamall.seaca.push + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.tasks.await +import ru.kalinamall.seaca.data.SacRepository + +object FcmRegistrar { + suspend fun registerIfPossible(context: android.content.Context, repo: SacRepository) { + if (repo.session.getAccessToken() == null) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) return + } + runCatching { + val token = FirebaseMessaging.getInstance().token.await() + repo.registerFcmToken(token) + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt new file mode 100644 index 0000000..dca3b80 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt @@ -0,0 +1,76 @@ +package ru.kalinamall.seaca.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import ru.kalinamall.seaca.MainActivity +import ru.kalinamall.seaca.R + +object NotificationHelper { + const val CHANNEL_DEFAULT = "sac_default" + const val CHANNEL_HIGH = "sac_high" + const val CHANNEL_CRITICAL = "sac_critical" + + fun ensureChannels(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val mgr = context.getSystemService(NotificationManager::class.java) + mgr.createNotificationChannel( + NotificationChannel(CHANNEL_DEFAULT, context.getString(R.string.channel_default), NotificationManager.IMPORTANCE_DEFAULT), + ) + mgr.createNotificationChannel( + NotificationChannel(CHANNEL_HIGH, context.getString(R.string.channel_high), NotificationManager.IMPORTANCE_HIGH), + ) + mgr.createNotificationChannel( + NotificationChannel(CHANNEL_CRITICAL, context.getString(R.string.channel_critical), NotificationManager.IMPORTANCE_HIGH), + ) + } + + fun show( + context: Context, + title: String, + body: String, + severity: String?, + kind: String?, + id: String?, + ) { + ensureChannels(context) + val channel = when (severity?.lowercase()) { + "critical" -> CHANNEL_CRITICAL + "high" -> CHANNEL_HIGH + else -> CHANNEL_DEFAULT + } + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + when (kind) { + "event" -> putExtra("deep_link", "event/$id") + "problem" -> putExtra("deep_link", "problem/$id") + } + } + val pending = PendingIntent.getActivity( + context, + (id ?: title).hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(context, channel) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pending) + .setPriority( + when (channel) { + CHANNEL_CRITICAL -> NotificationCompat.PRIORITY_MAX + CHANNEL_HIGH -> NotificationCompat.PRIORITY_HIGH + else -> NotificationCompat.PRIORITY_DEFAULT + }, + ) + .build() + context.getSystemService(NotificationManager::class.java) + .notify((id ?: title).hashCode(), notification) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt new file mode 100644 index 0000000..c7bc012 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt @@ -0,0 +1,38 @@ +package ru.kalinamall.seaca.push + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.SacRepository + +class SeacaMessagingService : FirebaseMessagingService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val repo by lazy { SacRepository(applicationContext) } + + override fun onMessageReceived(message: RemoteMessage) { + val data = message.data + val title = message.notification?.title ?: data["title"] ?: "SAC" + val body = message.notification?.body ?: data["body"] ?: "" + NotificationHelper.show( + context = this, + title = title, + body = body, + severity = data["severity"], + kind = data["kind"], + id = data["id"], + ) + } + + override fun onNewToken(token: String) { + scope.launch { + runCatching { + if (repo.session.getAccessToken() != null) { + repo.registerFcmToken(token) + } + } + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/security/BiometricGate.kt b/app/src/main/java/ru/kalinamall/seaca/security/BiometricGate.kt new file mode 100644 index 0000000..6cd465d --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/security/BiometricGate.kt @@ -0,0 +1,48 @@ +package ru.kalinamall.seaca.security + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +object BiometricGate { + fun canAuthenticate(activity: FragmentActivity): Boolean { + val mgr = BiometricManager.from(activity) + return mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + } + + fun authenticate( + activity: FragmentActivity, + onSuccess: () -> Unit, + onFailure: () -> Unit, + ) { + if (!canAuthenticate(activity)) { + onSuccess() + return + } + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onFailure() + } + + override fun onAuthenticationFailed() = Unit + }, + ) + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle("Seaca") + .setSubtitle("Подтвердите доступ") + .setNegativeButtonText("Отмена") + .build(), + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/security/TlsTrust.kt b/app/src/main/java/ru/kalinamall/seaca/security/TlsTrust.kt new file mode 100644 index 0000000..53bea49 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/security/TlsTrust.kt @@ -0,0 +1,38 @@ +package ru.kalinamall.seaca.security + +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +object TlsTrust { + private val permissiveManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + override fun getAcceptedIssuers(): Array = emptyArray() + } + + private val defaultManager: X509TrustManager by lazy { + val tmf = javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm(), + ) + tmf.init(null as java.security.KeyStore?) + tmf.trustManagers.filterIsInstance().first() + } + + fun trustManager(trustAll: Boolean): X509TrustManager = if (trustAll) permissiveManager else defaultManager + + fun socketFactory(trustAll: Boolean): SSLSocketFactory { + val ctx = SSLContext.getInstance("TLS") + ctx.init(null, arrayOf(trustManager(trustAll)), SecureRandom()) + return ctx.socketFactory + } + + fun hostnameVerifier(trustAll: Boolean): HostnameVerifier = + if (trustAll) HostnameVerifier { _, _ -> true } else HostnameVerifier { host, session -> + javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ConnectScreen.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ConnectScreen.kt new file mode 100644 index 0000000..f95142c --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ConnectScreen.kt @@ -0,0 +1,125 @@ +package ru.kalinamall.seaca.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.SacRepository + +@Composable +fun ConnectScreen( + repo: SacRepository, + onConnected: () -> Unit, +) { + var serverUrl by remember { mutableStateOf("") } + var enrollmentCode by remember { mutableStateOf("") } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf(android.os.Build.MODEL) } + var trustCert by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Seaca", style = MaterialTheme.typography.headlineMedium) + Text("Подключение к Security Alert Center", style = MaterialTheme.typography.bodyMedium) + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("URL сервера") }, + placeholder = { Text("https://sac.example.com") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = enrollmentCode, + onValueChange = { enrollmentCode = it }, + label = { Text("Код регистрации") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Логин SAC (если нужен)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Пароль SAC") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text("Имя устройства") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Column(horizontalAlignment = Alignment.Start) { + Checkbox(checked = trustCert, onCheckedChange = { trustCert = it }) + Text("Доверять сертификату (внутренняя сеть / self-signed)") + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button( + onClick = { + loading = true + error = null + scope.launch { + try { + repo.checkHealth(serverUrl, trustCert) + repo.enroll( + serverUrl = serverUrl, + enrollmentCode = enrollmentCode, + username = username, + password = password, + displayName = displayName, + fcmToken = null, + trustAll = trustCert, + ) + onConnected() + } catch (e: Exception) { + error = when (e) { + is retrofit2.HttpException -> SacRepository.parseError(e) + else -> e.message ?: "Ошибка подключения" + } + } finally { + loading = false + } + } + }, + enabled = !loading && serverUrl.isNotBlank() && enrollmentCode.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (loading) "Подключение…" else "Подключиться") + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt new file mode 100644 index 0000000..442e901 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -0,0 +1,245 @@ +package ru.kalinamall.seaca.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.HostDetail +import ru.kalinamall.seaca.data.ProblemDetail +import ru.kalinamall.seaca.data.SacRepository + +@Composable +fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + + LaunchedEffect(id) { + loading = true + try { + data = repo.withAuth { it.getEvent(id) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + + DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) { + val d = data ?: return@DetailFrame + Field("Severity", d.severity) + Field("Тип", d.type) + Field("Хост", d.hostname) + Field("Время", d.occurredAt) + Field("Кратко", d.summary) + d.details?.let { Field("Детали", it.toPrettyString()) } + } +} + +@Composable +fun ProblemDetailScreen( + repo: SacRepository, + id: Long, + canOperate: Boolean, + onBack: () -> Unit, + onChanged: () -> Unit, +) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var acting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + fun load() { + scope.launch { + loading = true + error = null + try { + data = repo.withAuth { it.getProblem(id) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + } + + LaunchedEffect(id) { load() } + + DetailFrame(title = data?.title ?: "Проблема", loading, error, onBack) { + val d = data ?: return@DetailFrame + Field("Статус", d.status) + Field("Severity", d.severity) + Field("Хост", d.hostname ?: "—") + Field("Кратко", d.summary) + if (canOperate && d.status in listOf("open", "acknowledged")) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (d.status == "open") { + Button( + enabled = !acting, + onClick = { + acting = true + scope.launch { + try { + repo.withAuth { it.ackProblem(id) } + load() + onChanged() + } catch (e: Exception) { + error = e.message + } finally { + acting = false + } + } + }, + ) { Text("Подтвердить") } + } + Button( + enabled = !acting, + onClick = { + acting = true + scope.launch { + try { + repo.withAuth { it.resolveProblem(id) } + load() + onChanged() + } catch (e: Exception) { + error = e.message + } finally { + acting = false + } + } + }, + ) { Text("Закрыть") } + } + } + if (d.events.isNotEmpty()) { + Text("Связанные события", style = MaterialTheme.typography.titleMedium) + d.events.forEach { e -> + Text("${e.severity} · ${e.title}\n${e.occurredAt}", style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + + LaunchedEffect(id) { + loading = true + try { + data = repo.withAuth { it.getHost(id) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + + DetailFrame(title = data?.hostname ?: "Хост", loading, error, onBack) { + val d = data ?: return@DetailFrame + Field("Продукт", d.product) + Field("Версия", d.productVersion ?: "—") + Field("ОС", "${d.osFamily} ${d.osVersion.orEmpty()}".trim()) + Field("Агент", d.agentStatus) + Field("Последний контакт", d.lastSeenAt) + d.inventory?.let { Field("Inventory", it.toPrettyString()) } + } +} + +@Composable +fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { + var baseUrl by remember { mutableStateOf(null) } + var username by remember { mutableStateOf(null) } + var role by remember { mutableStateOf(null) } + var deviceId by remember { mutableStateOf(0) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + baseUrl = repo.session.getBaseUrl() + username = repo.session.getUsername() + role = repo.session.getRole() + deviceId = repo.session.getDeviceId() + } + + Column( + Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Моё устройство", style = MaterialTheme.typography.titleLarge) + Field("Сервер", baseUrl ?: "—") + Field("Пользователь", username ?: "—") + Field("Роль", role ?: "—") + Field("Device ID", deviceId.toString()) + Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME) + Button( + onClick = { + scope.launch { + repo.logout() + onLogout() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Выйти") } + } +} + +@Composable +private fun DetailFrame( + title: String, + loading: Boolean, + error: String?, + onBack: () -> Unit, + content: @Composable () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = onBack) { Text("Назад") } + Text(title, style = MaterialTheme.typography.titleLarge) + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (loading) { + CircularProgressIndicator() + } else { + content() + } + } +} + +@Composable +private fun Field(label: String, value: String) { + Column(Modifier.fillMaxWidth()) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +private fun JsonElement.toPrettyString(): String = toString() diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt new file mode 100644 index 0000000..347023d --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -0,0 +1,303 @@ +package ru.kalinamall.seaca.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.DashboardSummary +import ru.kalinamall.seaca.data.EventListResponse +import ru.kalinamall.seaca.data.EventSummary +import ru.kalinamall.seaca.data.HostListResponse +import ru.kalinamall.seaca.data.HostSummary +import ru.kalinamall.seaca.data.ProblemListResponse +import ru.kalinamall.seaca.data.ProblemSummary +import ru.kalinamall.seaca.data.SacRepository + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen(repo: SacRepository) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + fun load() { + scope.launch { + loading = true + error = null + try { + data = repo.withAuth { it.dashboardSummary() } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + } + + LaunchedEffect(Unit) { load() } + + PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.fillMaxSize()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + data?.let { d -> + StatCard("События 24ч", d.eventsLast24h.toString()) + StatCard("Проблемы open", d.problemsOpen.toString()) + StatCard("Хосты", "${d.hostsTotal} (stale: ${d.hostsStale})") + Text("Последние события", style = MaterialTheme.typography.titleMedium) + d.recentEvents.take(8).forEach { e -> + Text("${e.severity} · ${e.title}", style = MaterialTheme.typography.bodySmall) + } + } ?: if (loading) CircularProgressIndicator() + } + } +} + +@Composable +private fun StatCard(label: String, value: String) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium) + Text(value, style = MaterialTheme.typography.headlineSmall) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + PagedListScreen( + title = "События", + loadPage = { page, severity -> + repo.withAuth { it.listEvents(page = page, pageSize = 30, severity = severity) } + }, + itemKey = { it.id }, + itemLabel = { "${it.severity} · ${it.title}" }, + itemSub = { "${it.hostname} · ${it.occurredAt}" }, + onOpen = onOpen, + severities = listOf(null, "info", "warning", "high", "critical"), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + PagedListScreen( + title = "Проблемы", + loadPage = { page, filter -> + repo.withAuth { + it.listProblems(page = page, pageSize = 30, status = filter ?: "open") + } + }, + itemKey = { it.id }, + itemLabel = { "${it.severity} · ${it.title}" }, + itemSub = { "${it.status} · ${it.hostname ?: "—"}" }, + onOpen = onOpen, + severities = listOf("open", "acknowledged", "resolved"), + filterLabel = "Статус", + ) +} + +@Composable +fun HostsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + PagedListScreen( + title = "Хосты", + loadPage = { page, _ -> + repo.withAuth { it.listHosts(page = page, pageSize = 30) } + }, + itemKey = { it.id }, + itemLabel = { it.hostname }, + itemSub = { "${it.agentStatus} · ${it.product}" }, + onOpen = onOpen, + severities = emptyList(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + var reportType by remember { mutableStateOf("report.daily.ssh") } + var expanded by remember { mutableStateOf(false) } + var page by remember { mutableIntStateOf(1) } + var data by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun load() { + scope.launch { + loading = true + try { + data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + } + + LaunchedEffect(page, reportType) { load() } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + readOnly = true, + value = reportType, + onValueChange = {}, + label = { Text("Тип отчёта") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("report.daily.ssh") }, + onClick = { reportType = "report.daily.ssh"; expanded = false; page = 1 }, + ) + DropdownMenuItem( + text = { Text("report.daily.rdp") }, + onClick = { reportType = "report.daily.rdp"; expanded = false; page = 1 }, + ) + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) { + LazyColumn { + items(data?.items.orEmpty(), key = { it.id }) { item -> + ListRow( + title = item.summary, + subtitle = "${item.hostname} · ${item.occurredAt}", + onClick = { onOpen(item.id) }, + ) + } + } + } + PagerBar(page, data?.total ?: 0, 30, onPrev = { if (page > 1) page-- }, onNext = { page++ }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PagedListScreen( + title: String, + loadPage: suspend (Int, String?) -> Any, + itemKey: (T) -> Long, + itemLabel: (T) -> String, + itemSub: (T) -> String, + onOpen: (Long) -> Unit, + severities: List, + filterLabel: String = "Severity", +) { + var page by remember { mutableIntStateOf(1) } + var filter by remember { mutableStateOf(severities.firstOrNull()) } + var items by remember { mutableStateOf>(emptyList()) } + var total by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun load() { + scope.launch { + loading = true + try { + @Suppress("UNCHECKED_CAST") + when (val res = loadPage(page, filter)) { + is EventListResponse -> { + items = res.items as List + total = res.total + } + is ProblemListResponse -> { + items = res.items as List + total = res.total + } + is HostListResponse -> { + items = res.items as List + total = res.total + } + } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + } + + LaunchedEffect(page, filter) { load() } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge) + if (severities.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + severities.forEach { s -> + FilterChip( + selected = filter == s, + onClick = { filter = s; page = 1 }, + label = { Text(s ?: "все") }, + ) + } + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) { + LazyColumn { + items(items, key = { itemKey(it) }) { item -> + ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(item)) } + } + } + } + PagerBar(page, total, 30, onPrev = { if (page > 1) page-- }, onNext = { if (page * 30 < total) page++ }) + } +} + +@Composable +private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) { + Card( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(onClick = onClick), + ) { + Column(Modifier.padding(12.dp)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Button(onClick = onPrev, enabled = page > 1) { Text("Назад") } + Text("Стр. $page · всего $total") + Button(onClick = onNext, enabled = page * pageSize < total) { Text("Вперёд") } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt b/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt new file mode 100644 index 0000000..8afab40 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt @@ -0,0 +1,32 @@ +package ru.kalinamall.seaca.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val SacDark = darkColorScheme( + primary = Color(0xFF3DB8FF), + background = Color(0xFF0F1419), + surface = Color(0xFF15202B), + onBackground = Color(0xFFC5D0DC), + onSurface = Color(0xFFC5D0DC), + error = Color(0xFFFF8A8A), +) + +private val SacLight = lightColorScheme( + primary = Color(0xFF1A6FA0), + background = Color(0xFFF4F6F8), + surface = Color(0xFFFFFFFF), +) + +@Composable +fun SeacaTheme(content: @Composable () -> Unit) { + val dark = isSystemInDarkTheme() + MaterialTheme( + colorScheme = if (dark) SacDark else SacLight, + content = content, + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..620debc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..aa9db9e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #15202B + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2c4a075 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Seaca + SAC уведомления + SAC: важные + SAC: критические + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4f5deb7 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +