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 @@
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..6115950
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..5beb9b9
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+ id("com.google.gms.google-services") version "4.4.2" apply false
+}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 9ef691c..6353438 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -17,61 +17,61 @@
---
-## v0.1 — Каркас приложения
+## v0.1 — Каркас приложения ✅
-- [ ] Gradle-проект: Compose, Navigation, Hilt/Koin, Retrofit, DataStore
-- [ ] Экран «Подключение»: URL SAC, код регистрации, логин/пароль
-- [ ] `POST /api/v1/auth/login` + обмен кода на `device_id` (когда API готов на SAC)
-- [ ] Secure storage: `base_url`, access/refresh token, `device_id`
-- [ ] Экран «Ошибка / нет сети» и проверка `GET /health`
-- [ ] Базовая навигация: Обзор, События, Проблемы, Хосты, Отчёты
-- [ ] CI: сборка debug APK (опционально — позже)
+- [x] Gradle-проект: Compose, Navigation, Retrofit, DataStore (DI-фреймворк не используем — `SacRepository` вручную)
+- [x] Экран «Подключение»: URL SAC, код регистрации, логин/пароль
+- [x] `POST /api/v1/mobile/enroll` + refresh-сессия
+- [x] Secure storage: `base_url`, access/refresh token, `device_id`
+- [x] Ошибки сети / `GET /health` перед enroll
+- [x] Базовая навигация: Обзор, События, Проблемы, Хосты, Отчёты
+- [ ] CI: сборка debug APK (локально — `gradlew assembleDebug`, JDK 17+)
**Зависимость:** SAC **≥ 0.9.0** — `mobile_devices_allowed`, enrollment codes, `POST /api/v1/mobile/enroll`
---
-## v0.2 — Паритет с веб-UI (чтение)
+## v0.2 — Паритет с веб-UI (чтение) ✅
-- [ ] Списки с пагинацией: events, problems, hosts, reports
-- [ ] Карточки: event, problem (+ связанные events), host (+ inventory)
-- [ ] Dashboard: виджеты как `GET /api/v1/dashboards/summary`
-- [ ] Фильтры events/problems (severity, type, host) — упрощённый набор
-- [ ] Pull-to-refresh; polling в foreground (SSE на Android в фоне не используем)
-- [ ] Тёмная тема (как веб)
-- [ ] Локализация: русский
+- [x] Списки с пагинацией: events, problems, hosts, reports
+- [x] Карточки: event, problem (+ связанные events), host (+ inventory)
+- [x] Dashboard: виджеты как `GET /api/v1/dashboards/summary`
+- [x] Фильтры events/problems (severity, status) — упрощённый набор
+- [x] Pull-to-refresh
+- [x] Тёмная тема (системная)
+- [x] Локализация: русский
---
-## v0.3 — Действия оператора
+## v0.3 — Действия оператора ✅
-- [ ] `POST /api/v1/problems/{id}/ack` и `/resolve`
-- [ ] Обновление списков после действия
-- [ ] Скрытие admin-only API (403) — без попыток открыть settings/users
-- [ ] Биометрический замок при возврате в приложение
-- [ ] Certificate pinning / «Доверять сертификату» для внутреннего TLS
+- [x] `POST /api/v1/problems/{id}/ack` и `/resolve`
+- [x] Обновление карточки после действия
+- [x] Admin-only API не вызываем из приложения
+- [x] Биометрический замок при возврате в приложение
+- [x] «Доверять сертификату» для внутреннего TLS (self-signed)
---
-## v0.4 — Push (FCM)
+## v0.4 — Push (FCM) ✅ (частично)
-- [ ] Firebase-проект, `google-services.json` (не в git)
-- [ ] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/{id}/fcm`
-- [ ] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
-- [ ] Notification channels по severity (critical / high / default)
-- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+)
-- [ ] Тестовый push из веб-SAC («Проверить push на устройстве»)
+- [x] Firebase-проект, `google-services.json` (не в git; пример `google-services.json.example`)
+- [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm`
+- [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
+- [x] Notification channels по severity (critical / high / default)
+- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+) — **после первого APK**
+- [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
-**Зависимость:** SAC worker — `use_push_mobile` в notification policy, `push_notify.py`
+**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
---
-## v0.5 — Полировка
+## v0.5 — Полировка (позже)
-- [ ] Refresh token rotation (`POST /api/v1/auth/refresh`)
+- [x] Refresh token rotation (`POST /api/v1/mobile/auth/refresh`) — уже в v0.1
- [ ] Виджет: число open problems
- [ ] Offline-кэш последних N событий (Room)
-- [ ] Экран «Моё устройство»: имя, сервер, версия, «Выйти»
+- [x] Экран «Моё устройство»: имя, сервер, версия, «Выйти» (базовый)
- [ ] Минимальная версия приложения (проверка с сервера)
---
diff --git a/docs/operator-guide.md b/docs/operator-guide.md
new file mode 100644
index 0000000..f3a16a6
--- /dev/null
+++ b/docs/operator-guide.md
@@ -0,0 +1,37 @@
+# Seaca — руководство оператора
+
+## Первая настройка
+
+1. Получите от администратора:
+ - адрес сервера (например `https://sac.kalinamall.ru`);
+ - код регистрации `sacmob_…`;
+ - при необходимости — логин и пароль SAC.
+2. Установите APK Seaca.
+3. На экране подключения введите данные → **Подключиться**.
+4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
+
+После успеха код больше не нужен — приложение запоминает сессию.
+
+## Разделы
+
+| Раздел | Содержание |
+|--------|------------|
+| Обзор | Сводка за 24 ч, открытые проблемы |
+| События | Лента с фильтром по severity |
+| Проблемы | Инциденты; для monitor/admin — подтвердить / закрыть |
+| Хосты | Агенты ssh-monitor / RDP-login-monitor |
+| Отчёты | Суточные отчёты SSH и RDP |
+
+## Push-уведомления
+
+Появятся после настройки FCM администратором. Нажатие на уведомление открывает карточку события или проблемы.
+
+## Блокировка
+
+При возврате в приложение может запрашиваться отпечаток / PIN устройства (если включено в системе).
+
+## Потеря доступа
+
+Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC.
+
+Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC).
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..09523c0
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..93e3f59
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..c89b903
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "Seaca"
+include(":app")