feat: Android client v0.4.0 — enroll, UI, ack/resolve, FCM

Kotlin Compose app: подключение по коду SAC, списки и карточки,
действия оператора, биометрия, push и deep links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-10 15:34:34 +10:00
parent c9fb5eefec
commit 23eb1ba64c
35 changed files with 2147 additions and 34 deletions
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".SeacaApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Seaca">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Seaca">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="seaca" android:host="event" />
<data android:scheme="seaca" android:host="problem" />
</intent-filter>
</activity>
<service
android:name=".push.SeacaMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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<String?>(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)
}
}
}
}
@@ -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)
}
}
@@ -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
}
@@ -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<EventSummary>,
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<ProblemSummary>,
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<ProblemEventItem> = 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<HostSummary>,
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<String, Int> = emptyMap(),
@SerialName("top_hosts") val topHosts: List<TopHostItem> = emptyList(),
@SerialName("top_event_types") val topEventTypes: List<TopTypeItem> = emptyList(),
@SerialName("recent_events") val recentEvents: List<EventSummary> = emptyList(),
)
@Serializable
data class ProblemActionResponse(
val id: Long,
val status: String,
)
@Serializable
data class ApiErrorBody(
val detail: String? = null,
)
@@ -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 <T> 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<ApiErrorBody>(body).detail ?: body
} catch (_: Exception) {
body.ifBlank { "HTTP ${e.code()}" }
}
}
}
}
@@ -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<Boolean> = 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"
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
}
}
@@ -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(),
)
}
}
@@ -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<out X509Certificate>?, authType: String?) = Unit
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = 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<X509TrustManager>().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>(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)
}
}
@@ -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<String?>(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 "Подключиться")
}
}
}
@@ -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<EventDetail?>(null) }
var error by remember { mutableStateOf<String?>(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<ProblemDetail?>(null) }
var error by remember { mutableStateOf<String?>(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<HostDetail?>(null) }
var error by remember { mutableStateOf<String?>(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<String?>(null) }
var username by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf<String?>(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()
@@ -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<DashboardSummary?>(null) }
var error by remember { mutableStateOf<String?>(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<EventListResponse?>(null) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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 <T> PagedListScreen(
title: String,
loadPage: suspend (Int, String?) -> Any,
itemKey: (T) -> Long,
itemLabel: (T) -> String,
itemSub: (T) -> String,
onOpen: (Long) -> Unit,
severities: List<String?>,
filterLabel: String = "Severity",
) {
var page by remember { mutableIntStateOf(1) }
var filter by remember { mutableStateOf(severities.firstOrNull()) }
var items by remember { mutableStateOf<List<T>>(emptyList()) }
var total by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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<T>
total = res.total
}
is ProblemListResponse -> {
items = res.items as List<T>
total = res.total
}
is HostListResponse -> {
items = res.items as List<T>
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("Вперёд") }
}
}
@@ -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,
)
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DB8FF"
android:pathData="M54,30 L78,54 L54,78 L30,54 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#15202B</color>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Seaca</string>
<string name="channel_default">SAC уведомления</string>
<string name="channel_high">SAC: важные</string>
<string name="channel_critical">SAC: критические</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Seaca" parent="android:Theme.Material.NoActionBar" />
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>