chore(home): mirror from kalinamall (6398d48) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:58 +10:00
commit 9116e0472d
59 changed files with 5335 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Android / Gradle
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
*.ap_
*.dex
# Kotlin
.kotlin/
# Secrets
google-services.json
*.keystore
*.jks
local.properties
secrets.properties
app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt
# Env
.env
.env.*
# Cursor (не в git)
.cursor/
.cursorignore
+54
View File
@@ -0,0 +1,54 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.16` (versionCode 30)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Нет в приложении (только веб-SAC)
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
## Привязка
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
[docs/operator-guide.md](docs/operator-guide.md) · SAC [docs/seaca-mobile.md](https://git.papatramp.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
**Debug** (ключ свой на каждом ПК):
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
**Release** (один ключ на всех ПК — для обновлений поверх старой версии):
1. Положить `seaca-release.jks` в корень репозитория (не в git).
2. Скопировать `secrets.properties.example``secrets.properties`, подставить пароль (ASCII).
3. `.\gradlew assembleRelease` — APK: `app/build/outputs/apk/release/app-release.apk`
## Репозитории
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.papatramp.ru/PapaTramp/seaca |
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/rdp-login-monitor |
+103
View File
@@ -0,0 +1,103 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
val secretsFile = rootProject.file("secrets.properties")
val releaseSecrets = Properties().apply {
if (secretsFile.exists()) {
secretsFile.inputStream().use { load(it) }
}
}
val releaseKeystoreConfigured = secretsFile.exists() &&
releaseSecrets.getProperty("SEACA_STORE_FILE") != null
android {
namespace = "ru.kalinamall.seaca"
compileSdk = 35
defaultConfig {
applicationId = "ru.kalinamall.seaca"
minSdk = 26
targetSdk = 35
versionCode = 30
versionName = "0.5.16"
}
signingConfigs {
if (releaseKeystoreConfigured) {
create("release") {
storeFile = rootProject.file(releaseSecrets.getProperty("SEACA_STORE_FILE"))
storePassword = releaseSecrets.getProperty("SEACA_STORE_PASSWORD")
keyAlias = releaseSecrets.getProperty("SEACA_KEY_ALIAS")
keyPassword = releaseSecrets.getProperty("SEACA_KEY_PASSWORD")
}
}
}
buildTypes {
release {
if (releaseKeystoreConfigured) {
signingConfig = signingConfigs.getByName("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-process: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")
}
+29
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
# Seaca defaults sufficient for debug/release without minify
+52
View File
@@ -0,0 +1,52 @@
<?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>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,335 @@
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.fillMaxSize
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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateOf as composeMutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.ExternalPickerHost
import ru.kalinamall.seaca.ui.RingtonePicker
import ru.kalinamall.seaca.ui.screens.ReportsScreen
import ru.kalinamall.seaca.ui.theme.SeacaTheme
class MainActivity : FragmentActivity(), ExternalPickerHost {
private lateinit var repo: SacRepository
private val biometricUnlocked = mutableStateOf(true)
private var pendingBiometric = false
private var externalPickerFlowActive = false
private val deepLinkState = composeMutableStateOf<String?>(null)
override fun beginExternalPickerFlow() {
externalPickerFlowActive = true
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
repo = SacRepository(applicationContext)
requestNotificationPermission()
deepLinkState.value = extractDeepLink(intent)
if (shouldRequireBiometric()) {
biometricUnlocked.value = false
pendingBiometric = true
}
setContent {
val unlocked by biometricUnlocked
SeacaApp(
repo = repo,
deepLink = deepLinkState.value,
onDeepLinkConsumed = { deepLinkState.value = null },
biometricUnlocked = unlocked,
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
deepLinkState.value = extractDeepLink(intent)
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@Suppress("DEPRECATION")
super.onActivityResult(requestCode, resultCode, data)
RingtonePicker.handleActivityResult(this, requestCode, resultCode, data)
}
override fun onStop() {
super.onStop()
if (externalPickerFlowActive) return
if (shouldRequireBiometric()) {
biometricUnlocked.value = false
pendingBiometric = true
}
}
override fun onResume() {
super.onResume()
if (externalPickerFlowActive) {
externalPickerFlowActive = false
return
}
if (pendingBiometric && !biometricUnlocked.value && repo.session.getAccessToken() != null) {
pendingBiometric = false
BiometricGate.authenticate(
activity = this,
onSuccess = { biometricUnlocked.value = true },
onFailure = { finish() },
)
}
}
private fun shouldRequireBiometric(): Boolean =
repo.session.getAccessToken() != null && BiometricGate.canAuthenticate(this)
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"
}
var mainTabIndex by rememberSaveable { mutableIntStateOf(MainTab.Dashboard.ordinal) }
var dashboardRefreshTick by rememberSaveable { mutableIntStateOf(0) }
val mainTab = MainTab.entries[mainTabIndex.coerceIn(MainTab.entries.indices)]
SeacaTheme {
if (!loggedIn) {
ConnectScreen(
repo = repo,
onConnected = {
scope.launch { FcmRegistrar.registerIfPossible(context, repo) }
},
)
return@SeacaTheme
}
if (!biometricUnlocked) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
return@SeacaTheme
}
NavHost(navController = nav, startDestination = "main") {
composable("main") {
MainShell(
repo = repo,
selectedTab = mainTab,
dashboardRefreshTick = dashboardRefreshTick,
onTabSelected = { tab ->
mainTabIndex = tab.ordinal
if (tab == MainTab.Dashboard) {
dashboardRefreshTick++
}
},
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 = repo,
id = entry.arguments?.getLong("id") ?: 0L,
onOpenEvent = { nav.navigate("event/$it") },
onBack = { 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,
selectedTab: MainTab,
dashboardRefreshTick: Int,
onTabSelected: (MainTab) -> Unit,
onOpenEvent: (Long) -> Unit,
onOpenProblem: (Long) -> Unit,
onOpenHost: (Long) -> Unit,
onOpenDevice: () -> Unit,
) {
Scaffold(
bottomBar = {
NavigationBar {
MainTab.entries.forEach { item ->
NavigationBarItem(
selected = selectedTab == item,
onClick = { onTabSelected(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 (selectedTab) {
MainTab.Dashboard -> DashboardScreen(repo, refreshKey = dashboardRefreshTick)
MainTab.Events -> EventsScreen(repo, onOpenEvent)
MainTab.Problems -> ProblemsScreen(repo, onOpenProblem)
MainTab.Hosts -> HostsScreen(repo, onOpenHost)
MainTab.Reports -> ReportsScreen(repo, onOpenEvent)
}
}
}
}
@@ -0,0 +1,23 @@
package ru.kalinamall.seaca
import android.app.Application
import kotlinx.coroutines.runBlocking
import ru.kalinamall.seaca.data.SessionStore
import ru.kalinamall.seaca.push.AppForegroundState
import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
class SeacaApplication : Application() {
override fun onCreate() {
super.onCreate()
AppForegroundState.bind(this)
val store = SessionStore(this)
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
}
NotificationHelper.ensureChannels(
this,
NotificationSoundResolver.resolve(this, settings),
)
}
}
@@ -0,0 +1,18 @@
package ru.kalinamall.seaca.data
enum class DateTimeDisplayMode(
val storageKey: String,
val label: String,
val pattern: String?,
) {
SLASH("slash", "ДД/ММ/ГГГГ ЧЧ:мм:сс", "dd/MM/yyyy HH:mm:ss"),
DASH("dash", "ДД-ММ-ГГГГ ЧЧ:мм:сс", "dd-MM-yyyy HH:mm:ss"),
DOT("dot", "ДД.ММ.ГГГГ ЧЧ:мм:сс", "dd.MM.yyyy HH:mm:ss"),
LOCALE("locale", "Как в системе телефона", null),
;
companion object {
fun fromStorage(value: String?): DateTimeDisplayMode =
entries.firstOrNull { it.storageKey == value } ?: SLASH
}
}
@@ -0,0 +1,16 @@
package ru.kalinamall.seaca.data
enum class NotificationMode(
val storageKey: String,
val label: String,
) {
PUSH_SOUND("push_sound", "Push и звук"),
PUSH_SILENT("push_silent", "Push без звука"),
OFF("off", "Без push и звука"),
;
companion object {
fun fromStorage(value: String?): NotificationMode =
entries.firstOrNull { it.storageKey == value } ?: PUSH_SOUND
}
}
@@ -0,0 +1,20 @@
package ru.kalinamall.seaca.data
enum class NotificationSoundSource(
val storageKey: String,
val label: String,
) {
SYSTEM("system", "Системный звук"),
CUSTOM("custom", "Свой звук"),
;
companion object {
fun fromStorage(value: String?): NotificationSoundSource =
entries.firstOrNull { it.storageKey == value } ?: SYSTEM
}
}
data class NotificationSoundSettings(
val source: NotificationSoundSource,
val customUri: String?,
)
@@ -0,0 +1,92 @@
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("host_id") hostId: Long? = null,
@Query("include_hidden") includeHidden: Boolean? = 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
@POST("api/v1/events/{id}/actions/qwinsta")
suspend fun postEventQwinsta(@Path("id") id: Long): AgentCommandResponse
@POST("api/v1/events/{id}/actions/logoff")
suspend fun postEventLogoff(
@Path("id") id: Long,
@Body body: LogoffActionRequest,
): AgentCommandResponse
@POST("api/v1/events/{id}/actions/terminate-session")
suspend fun postEventTerminateSession(
@Path("id") id: Long,
@Body body: EventSessionTerminateRequest = EventSessionTerminateRequest(),
): SessionActionResponse
@POST("api/v1/hosts/{id}/actions/sessions/list")
suspend fun postHostSessionsList(@Path("id") id: Long): HostSessionsResponse
@POST("api/v1/hosts/{id}/actions/sessions/terminate")
suspend fun postHostSessionTerminate(
@Path("id") id: Long,
@Body body: HostSessionTerminateRequest,
): SessionActionResponse
@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,265 @@
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,
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
)
@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,
@SerialName("actor_user") val actorUser: String? = null,
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
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 LogoffActionRequest(
@SerialName("session_id") val sessionId: Int,
)
@Serializable
data class HostSessionItem(
@SerialName("session_id") val sessionId: String,
val user: String,
val tty: String? = null,
val state: String? = null,
@SerialName("source_ip") val sourceIp: String? = null,
@SerialName("session_name") val sessionName: String? = null,
)
@Serializable
data class HostSessionsResponse(
val ok: Boolean,
val message: String,
val target: String? = null,
val sessions: List<HostSessionItem> = emptyList(),
)
@Serializable
data class HostSessionTerminateRequest(
@SerialName("session_id") val sessionId: String,
)
@Serializable
data class EventSessionTerminateRequest(
@SerialName("session_id") val sessionId: String? = null,
)
@Serializable
data class SessionActionResponse(
val ok: Boolean,
val message: String,
val target: String? = null,
val stdout: String? = null,
val stderr: String? = null,
)
@Serializable
data class AgentCommandResponse(
@SerialName("command_uuid") val commandUuid: String,
@SerialName("command_type") val commandType: String,
val status: String,
@SerialName("result_stdout") val resultStdout: String? = null,
@SerialName("result_stderr") val resultStderr: String? = null,
val target: String? = null,
@SerialName("client_hostname") val clientHostname: String? = null,
@SerialName("internal_ip") val internalIp: String? = null,
)
@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(120, 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,151 @@
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)
}
val notificationMode: Flow<NotificationMode> = context.dataStore.data.map { prefs ->
NotificationMode.fromStorage(prefs[KEY_NOTIFICATION_MODE])
}
val dateTimeDisplayMode: Flow<DateTimeDisplayMode> = context.dataStore.data.map { prefs ->
DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT])
}
val notificationSoundSettings: Flow<NotificationSoundSettings> = context.dataStore.data.map { prefs ->
NotificationSoundSettings(
source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]),
customUri = prefs[KEY_NOTIFICATION_SOUND_URI],
)
}
suspend fun getNotificationMode(): NotificationMode =
NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE])
suspend fun getDateTimeDisplayMode(): DateTimeDisplayMode =
DateTimeDisplayMode.fromStorage(context.dataStore.data.first()[KEY_DATE_TIME_FORMAT])
suspend fun getNotificationSoundSettings(): NotificationSoundSettings {
val prefs = context.dataStore.data.first()
return NotificationSoundSettings(
source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]),
customUri = prefs[KEY_NOTIFICATION_SOUND_URI],
)
}
suspend fun setNotificationMode(mode: NotificationMode) {
context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey }
}
suspend fun setDateTimeDisplayMode(mode: DateTimeDisplayMode) {
context.dataStore.edit { it[KEY_DATE_TIME_FORMAT] = mode.storageKey }
}
suspend fun setNotificationSound(source: NotificationSoundSource, customUri: String?) {
context.dataStore.edit {
it[KEY_NOTIFICATION_SOUND_SOURCE] = source.storageKey
if (customUri.isNullOrBlank()) {
it.remove(KEY_NOTIFICATION_SOUND_URI)
} else {
it[KEY_NOTIFICATION_SOUND_URI] = customUri
}
}
}
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 val KEY_NOTIFICATION_MODE = stringPreferencesKey("notification_mode")
private val KEY_DATE_TIME_FORMAT = stringPreferencesKey("date_time_format")
private val KEY_NOTIFICATION_SOUND_SOURCE = stringPreferencesKey("notification_sound_source")
private val KEY_NOTIFICATION_SOUND_URI = stringPreferencesKey("notification_sound_uri")
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,32 @@
package ru.kalinamall.seaca.push
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
/** True, пока хотя бы одна activity приложения видима (ProcessLifecycleOwner.onStart). */
object AppForegroundState {
@Volatile
private var inForeground = false
private lateinit var appContext: Context
fun bind(context: Context) {
appContext = context.applicationContext
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
inForeground = true
NotificationHelper.clearAll(appContext)
}
override fun onStop(owner: LifecycleOwner) {
inForeground = false
}
},
)
}
fun isInForeground(): Boolean = inForeground
}
@@ -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,155 @@
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.media.AudioAttributes
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import ru.kalinamall.seaca.MainActivity
import ru.kalinamall.seaca.R
object NotificationHelper {
private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
private const val CHANNEL_HIGH_SILENT = "sac_high_silent"
private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical")
private var appliedSoundKey: String? = null
private var soundChannelSuffix: String = "sys"
private var channelDefault = "sac_default_sys"
private var channelHigh = "sac_high_sys"
private var channelCritical = "sac_critical_sys"
fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val soundKey = soundUri.toString()
val mgr = context.getSystemService(NotificationManager::class.java)
if (soundKey != appliedSoundKey) {
legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
if (appliedSoundKey != null) {
mgr.deleteNotificationChannel(channelDefault)
mgr.deleteNotificationChannel(channelHigh)
mgr.deleteNotificationChannel(channelCritical)
}
appliedSoundKey = soundKey
soundChannelSuffix = soundKey.hashCode().toUInt().toString(16).take(8)
channelDefault = "sac_default_$soundChannelSuffix"
channelHigh = "sac_high_$soundChannelSuffix"
channelCritical = "sac_critical_$soundChannelSuffix"
}
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
mgr.createNotificationChannel(
soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
soundChannel(context, channelCritical, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
)
mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent))
mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent))
mgr.createNotificationChannel(silentChannel(context, CHANNEL_CRITICAL_SILENT, R.string.channel_critical_silent))
}
private fun soundChannel(
context: Context,
id: String,
nameRes: Int,
importance: Int,
soundUri: Uri,
audioAttributes: AudioAttributes,
): NotificationChannel = NotificationChannel(id, context.getString(nameRes), importance).apply {
setSound(soundUri, audioAttributes)
enableVibration(true)
}
private fun silentChannel(context: Context, id: String, nameRes: Int): NotificationChannel =
NotificationChannel(id, context.getString(nameRes), NotificationManager.IMPORTANCE_DEFAULT).apply {
setSound(null, null)
enableVibration(false)
setShowBadge(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setAllowBubbles(false)
}
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
}
fun clearAll(context: Context) {
context.getSystemService(NotificationManager::class.java).cancelAll()
}
fun show(
context: Context,
title: String,
body: String,
severity: String?,
kind: String?,
id: String?,
silent: Boolean = false,
soundUri: Uri = NotificationSoundResolver.defaultNotificationUri(),
) {
ensureChannels(context, soundUri)
val channel = resolveChannel(severity, silent)
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 builder = NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pending)
.setPriority(
when {
silent -> NotificationCompat.PRIORITY_DEFAULT
channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT ->
NotificationCompat.PRIORITY_MAX
channel == channelHigh || channel == CHANNEL_HIGH_SILENT ->
NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
},
)
if (silent) {
builder.setSilent(true)
builder.setSound(null)
builder.setVibrate(null)
builder.setDefaults(0)
}
context.getSystemService(NotificationManager::class.java)
.notify((id ?: title).hashCode(), builder.build())
}
private fun resolveChannel(severity: String?, silent: Boolean): String {
if (silent) {
return when (severity?.lowercase()) {
"critical" -> CHANNEL_CRITICAL_SILENT
"high" -> CHANNEL_HIGH_SILENT
else -> CHANNEL_DEFAULT_SILENT
}
}
return when (severity?.lowercase()) {
"critical" -> channelCritical
"high" -> channelHigh
else -> channelDefault
}
}
}
@@ -0,0 +1,109 @@
package ru.kalinamall.seaca.push
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import androidx.core.content.FileProvider
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.SessionStore
import java.io.File
object NotificationSoundResolver {
private const val CUSTOM_SOUND_FILE = "custom_notification_sound"
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
return when (settings.source) {
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
NotificationSoundSource.CUSTOM -> {
playableCustomUri(context, settings.customUri) ?: defaultNotificationUri()
}
}
}
suspend fun normalizeStoredSettings(context: Context, store: SessionStore): NotificationSoundSettings {
val settings = store.getNotificationSoundSettings()
if (settings.source != NotificationSoundSource.CUSTOM || settings.customUri.isNullOrBlank()) {
return settings
}
if (isInternalSoundUri(context, settings.customUri)) {
return settings
}
val imported = importCustomSound(context, Uri.parse(settings.customUri)) ?: return settings
store.setNotificationSound(NotificationSoundSource.CUSTOM, imported)
return store.getNotificationSoundSettings()
}
fun defaultNotificationUri(): Uri =
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
fun displayName(context: Context, settings: NotificationSoundSettings): String {
return try {
val uri = resolve(context, settings)
RingtoneManager.getRingtone(context, uri)?.getTitle(context) ?: ""
} catch (_: Exception) {
""
}
}
/**
* Copies picker/system URI into app storage so FCM in a fresh process can read it.
* Returns stored URI string (FileProvider or android.resource).
*/
fun importCustomSound(context: Context, source: Uri): String? {
if (ContentResolver.SCHEME_ANDROID_RESOURCE == source.scheme) {
return source.toString()
}
if (defaultNotificationUri().toString() == source.toString()) {
return source.toString()
}
persistCustomUri(context, source)
val dest = File(context.filesDir, CUSTOM_SOUND_FILE)
return try {
context.contentResolver.openInputStream(source)?.use { input ->
dest.outputStream().use { output -> input.copyTo(output) }
} ?: return null
internalFileUri(context, dest).toString()
} catch (_: Exception) {
null
}
}
private fun playableCustomUri(context: Context, stored: String?): Uri? {
if (stored.isNullOrBlank()) return null
val parsed = Uri.parse(stored)
if (isInternalSoundUri(context, stored)) {
val file = File(context.filesDir, CUSTOM_SOUND_FILE)
return if (file.isFile) internalFileUri(context, file) else null
}
if (ContentResolver.SCHEME_ANDROID_RESOURCE == parsed.scheme) {
return parsed
}
return importCustomSound(context, parsed)?.let { Uri.parse(it) }
}
private fun isInternalSoundUri(context: Context, stored: String): Boolean {
val parsed = Uri.parse(stored)
return parsed.authority == fileProviderAuthority(context)
}
private fun internalFileUri(context: Context, file: File): Uri =
FileProvider.getUriForFile(context, fileProviderAuthority(context), file)
private fun fileProviderAuthority(context: Context): String =
"${context.packageName}.fileprovider"
private fun persistCustomUri(context: Context, uri: Uri) {
if (uri.scheme != ContentResolver.SCHEME_CONTENT) return
try {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (_: SecurityException) {
// Best-effort; internal copy is the reliable path.
}
}
}
@@ -0,0 +1,60 @@
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 kotlinx.coroutines.runBlocking
import ru.kalinamall.seaca.data.NotificationMode
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) {
if (AppForegroundState.isInForeground()) return
val mode = runBlocking { repo.session.getNotificationMode() }
when (mode) {
NotificationMode.OFF -> return
NotificationMode.PUSH_SILENT -> show(message, silent = true)
NotificationMode.PUSH_SOUND -> show(message, silent = false)
}
}
private fun show(message: RemoteMessage, silent: Boolean) {
val data = message.data
val title = data["title"] ?: message.notification?.title ?: "SAC"
val body = data["body"] ?: message.notification?.body ?: ""
val soundUri = if (silent) {
NotificationSoundResolver.defaultNotificationUri()
} else {
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session)
}
NotificationSoundResolver.resolve(this, settings)
}
NotificationHelper.show(
context = this,
title = title,
body = body,
severity = data["severity"],
kind = data["kind"],
id = data["id"],
silent = silent,
soundUri = soundUri,
)
}
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,61 @@
package ru.kalinamall.seaca.ui
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import ru.kalinamall.seaca.push.NotificationSoundResolver
object RingtonePicker {
private const val REQUEST_CODE = 9001
private var callback: (suspend (Uri?) -> Unit)? = null
fun launch(activity: FragmentActivity, currentUri: Uri, onResult: suspend (Uri?) -> Unit) {
callback = onResult
(activity as? ExternalPickerHost)?.beginExternalPickerFlow()
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false)
putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, NotificationSoundResolver.defaultNotificationUri())
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, currentUri)
}
try {
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, REQUEST_CODE)
} catch (_: ActivityNotFoundException) {
callback = null
throw ActivityNotFoundException()
}
}
fun handleActivityResult(activity: FragmentActivity, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != REQUEST_CODE) return
val onResult = callback ?: return
callback = null
val uri = if (resultCode == Activity.RESULT_OK) readResult(data) else null
activity.lifecycleScope.launch {
runCatching { onResult(uri) }
}
}
private fun readResult(data: Intent?): Uri? {
if (data == null) return null
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
} else {
@Suppress("DEPRECATION")
data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
}
}
interface ExternalPickerHost {
fun beginExternalPickerFlow()
}
@@ -0,0 +1,239 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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 retrofit2.HttpException
import ru.kalinamall.seaca.data.HostSessionItem
import ru.kalinamall.seaca.data.HostSessionTerminateRequest
import ru.kalinamall.seaca.data.SacRepository
@Composable
fun HostSessionsSection(
repo: SacRepository,
hostId: Long,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var loading by remember(hostId) { mutableStateOf(false) }
var loaded by remember(hostId) { mutableStateOf(false) }
var sessions by remember(hostId) { mutableStateOf<List<HostSessionItem>>(emptyList()) }
var error by remember(hostId) { mutableStateOf<String?>(null) }
var message by remember(hostId) { mutableStateOf<String?>(null) }
var messageOk by remember(hostId) { mutableStateOf(true) }
var terminatingId by remember(hostId) { mutableStateOf<String?>(null) }
var confirmSession by remember(hostId) { mutableStateOf<HostSessionItem?>(null) }
fun load() {
scope.launch {
loading = true
error = null
message = null
try {
val res = repo.withAuth { it.postHostSessionsList(hostId) }
if (!res.ok) {
error = res.message
sessions = emptyList()
} else {
sessions = res.sessions
loaded = true
}
} catch (e: Exception) {
error = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
loading = false
}
}
}
fun terminate(sessionId: String) {
scope.launch {
terminatingId = sessionId
message = null
try {
val res = repo.withAuth {
it.postHostSessionTerminate(hostId, HostSessionTerminateRequest(sessionId))
}
messageOk = res.ok
message = res.message
if (res.ok) {
sessions = sessions.filter { it.sessionId != sessionId }
}
} catch (e: Exception) {
messageOk = false
message = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminatingId = null
}
}
}
confirmSession?.let { session ->
AlertDialog(
onDismissRequest = { confirmSession = null },
title = { Text("Завершить сессию?") },
text = { Text("Завершить сессию ${session.sessionId} (${session.user})?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val sid = session.sessionId
confirmSession = null
terminate(sid)
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { confirmSession = null }) { Text("Отмена") }
},
)
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Залогиненные пользователи",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Linux: loginctl, Windows: qwinsta. Нужны учётные данные admin в настройках SAC.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
if (loaded) {
if (sessions.isEmpty()) {
Text(
"Активных сессий не найдено.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
sessions.forEach { session ->
HostSessionRow(
session = session,
terminating = terminatingId == session.sessionId,
enabled = terminatingId == null && !loading,
onTerminate = { confirmSession = session },
)
}
}
message?.let {
Text(
it,
color = if (messageOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
OutlinedButton(
enabled = !loading,
onClick = { load() },
) {
if (loading) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator()
Text("Загрузка…")
}
} else {
Text(if (loaded) "Обновить" else "Показать пользователей")
}
}
}
}
@Composable
private fun HostSessionRow(
session: HostSessionItem,
terminating: Boolean,
enabled: Boolean,
onTerminate: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(session.user, style = MaterialTheme.typography.bodyMedium)
Text(
buildString {
append("ID ${session.sessionId}")
session.tty?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
session.sessionName?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
session.state?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (terminating) {
CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp))
} else {
Button(
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = onTerminate,
) { Text("Завершить") }
}
}
}
}
@Composable
fun EventSessionTerminateButton(
enabled: Boolean,
loading: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
OutlinedButton(
enabled = enabled && !loading,
onClick = onClick,
modifier = modifier,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(if (loading) "" else "Завершить")
}
}
@@ -0,0 +1,48 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun BackArrowButton(
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад",
tint = if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
@Composable
fun ForwardArrowButton(
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Вперёд",
tint = if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
@@ -0,0 +1,39 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val RdgAccessText = Color(0xFF7EC8E3)
private val RdgAccessBackground = Color(0x1A7EC8E3)
private val RdgAccessBorder = Color(0x667EC8E3)
@Composable
fun RdgAccessBadge(
path: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.border(1.dp, RdgAccessBorder, RoundedCornerShape(4.dp)),
shape = RoundedCornerShape(4.dp),
color = RdgAccessBackground,
) {
Text(
text = path,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = RdgAccessText,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.3.sp,
)
}
}
@@ -0,0 +1,43 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val RdgFlapText = Color(0xFFF0C674)
private val RdgFlapBackground = Color(0x1FF0C674)
private val RdgFlapBorder = Color(0x73F0C674)
@Composable
fun RdgFlapBadge(
modifier: Modifier = Modifier,
pairEventId: Long? = null,
) {
val label = buildString {
append("RDG FLAP")
if (pairEventId != null) append(" #$pairEventId")
}
Surface(
modifier = modifier
.border(1.dp, RdgFlapBorder, RoundedCornerShape(4.dp)),
shape = RoundedCornerShape(4.dp),
color = RdgFlapBackground,
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = RdgFlapText,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.3.sp,
)
}
}
@@ -0,0 +1,334 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.launch
import retrofit2.HttpException
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.LogoffActionRequest
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.util.QwinstaSessionRow
import ru.kalinamall.seaca.ui.util.agentCommandError
import ru.kalinamall.seaca.ui.util.parseQwinstaSessions
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
class RdgQwinstaController(private val repo: SacRepository) {
var open by mutableStateOf(false)
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
var meta by mutableStateOf<String?>(null)
private set
var stdout by mutableStateOf<String?>(null)
private set
var sessions by mutableStateOf<List<QwinstaSessionRow>>(emptyList())
private set
var logoffSessionId by mutableStateOf<Int?>(null)
private set
var targetEventId by mutableStateOf<Long?>(null)
private set
private var actorUser: String? = null
fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
val targetId = rdgQwinstaEventId(event) ?: return
targetEventId = targetId
actorUser = event.actorUser
open = true
runQwinsta(scope)
}
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
val targetId = rdgQwinstaEventId(event) ?: return
targetEventId = targetId
actorUser = event.actorUser
open = true
runQwinsta(scope)
}
fun dismiss() {
if (!loading && logoffSessionId == null) {
open = false
}
}
fun runQwinsta(scope: kotlinx.coroutines.CoroutineScope) {
val targetId = targetEventId ?: return
scope.launch {
loading = true
error = null
stdout = null
sessions = emptyList()
try {
val cmd = repo.withAuth { it.postEventQwinsta(targetId) }
meta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target)
.joinToString(" · ")
.ifBlank { null }
val cmdError = agentCommandError(cmd)
if (cmdError != null) {
error = cmdError
} else {
val out = cmd.resultStdout.orEmpty()
stdout = out.ifBlank { null }
sessions = parseQwinstaSessions(out, actorUser)
}
} catch (e: Exception) {
error = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
loading = false
}
}
}
fun logoff(sessionId: Int, scope: kotlinx.coroutines.CoroutineScope, onDone: () -> Unit = {}) {
val targetId = targetEventId ?: return
scope.launch {
logoffSessionId = sessionId
error = null
try {
val cmd = repo.withAuth {
it.postEventLogoff(targetId, LogoffActionRequest(sessionId))
}
val cmdError = agentCommandError(cmd)
if (cmdError != null) {
error = cmdError
} else {
runQwinsta(scope)
}
} catch (e: Exception) {
error = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
logoffSessionId = null
onDone()
}
}
}
}
@Composable
fun rememberRdgQwinstaController(repo: SacRepository): RdgQwinstaController =
remember(repo) { RdgQwinstaController(repo) }
@Composable
fun RdgQwinstaDialog(
controller: RdgQwinstaController,
modifier: Modifier = Modifier,
) {
if (!controller.open) return
val scope = rememberCoroutineScope()
var confirmSessionId by remember { mutableIntStateOf(-1) }
var manualSessionId by remember { mutableStateOf("") }
if (confirmSessionId >= 0) {
AlertDialog(
onDismissRequest = { confirmSessionId = -1 },
title = { Text("Завершить сеанс?") },
text = { Text("Отправить logoff для сеанса ID $confirmSessionId на клиентском ПК?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val id = confirmSessionId
confirmSessionId = -1
controller.logoff(id, scope)
},
) { Text("logoff") }
},
dismissButton = {
TextButton(onClick = { confirmSessionId = -1 }) { Text("Отмена") }
},
)
}
Dialog(
onDismissRequest = { controller.dismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
) {
Column(
Modifier
.padding(16.dp)
.heightIn(max = 520.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Оборвать сессию", style = MaterialTheme.typography.titleLarge)
controller.meta?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
when {
controller.loading -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator()
Text("Ожидание ответа…")
}
}
controller.error != null -> {
Text(controller.error!!, color = MaterialTheme.colorScheme.error)
}
}
if (controller.sessions.isNotEmpty()) {
Text(
"Сеансы — нажмите logoff, чтобы оборвать зависшую сессию:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
controller.sessions.forEach { s ->
SessionLogoffRow(
session = s,
logoffEnabled = !controller.loading && controller.logoffSessionId == null,
logoffInProgress = controller.logoffSessionId == s.id,
onLogoff = { confirmSessionId = s.id },
)
}
} else if (!controller.loading && controller.stdout != null) {
Text(
"Сеансы не распознаны автоматически. Введите ID из вывода ниже:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = manualSessionId,
onValueChange = { manualSessionId = it.filter { ch -> ch.isDigit() } },
label = { Text("ID сеанса") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(
enabled = manualSessionId.isNotBlank() && !controller.loading && controller.logoffSessionId == null,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
manualSessionId.toIntOrNull()?.let { confirmSessionId = it }
},
) { Text("logoff") }
}
}
controller.stdout?.let { raw ->
Text("Вывод qwinsta", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(raw, style = MaterialTheme.typography.bodySmall)
}
if (controller.stdout == null && !controller.loading && controller.error == null) {
Text("Нет вывода", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(
enabled = !controller.loading && controller.logoffSessionId == null,
onClick = { controller.dismiss() },
) { Text("Закрыть") }
}
}
}
}
}
@Composable
private fun SessionLogoffRow(
session: QwinstaSessionRow,
logoffEnabled: Boolean,
logoffInProgress: Boolean,
onLogoff: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(session.sessionName, style = MaterialTheme.typography.bodyMedium)
Text(
"${session.userName} · ID ${session.id} · ${session.state}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (logoffInProgress) {
CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp))
} else {
Button(
enabled = logoffEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = onLogoff,
) { Text("logoff") }
}
}
}
}
@Composable
fun RdgQwinstaButton(
controller: RdgQwinstaController,
enabled: Boolean = true,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
OutlinedButton(
enabled = enabled && !controller.loading,
onClick = onClick,
modifier = modifier,
) {
Text(if (controller.loading) "" else "Оборвать сессию")
}
}
@@ -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://host.example") },
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,821 @@
package ru.kalinamall.seaca.ui.screens
import android.content.ActivityNotFoundException
import android.widget.Toast
import androidx.compose.runtime.rememberUpdatedState
import androidx.fragment.app.FragmentActivity
import android.media.RingtoneManager
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
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.collectAsState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import retrofit2.HttpException
import ru.kalinamall.seaca.R
import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.HostDetail
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.kalinamall.seaca.data.DateTimeDisplayMode
import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.LogoffActionRequest
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
import ru.kalinamall.seaca.ui.components.HostSessionsSection
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate
import ru.kalinamall.seaca.ui.util.sessionTerminateLabel
import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
import ru.kalinamall.seaca.ui.RingtonePicker
import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes
import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel
import ru.kalinamall.seaca.ui.util.formatEventDetails
import ru.kalinamall.seaca.ui.util.inventoryFieldLines
import ru.kalinamall.seaca.ui.util.isDailyReportType
import ru.kalinamall.seaca.ui.util.reportTextFromDetails
private enum class HostDetailTab(val label: String) {
Info("Информация"),
Events("События"),
}
@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) }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
var terminateLoading by remember { mutableStateOf(false) }
var terminateConfirm by remember { mutableStateOf(false) }
var terminateMessage by remember { mutableStateOf<String?>(null) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
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
if (!d.rdgAccessPath.isNullOrBlank()) {
Field("Путь RDS", d.rdgAccessPath)
}
if (hasRdgFlapUi(d)) {
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
}
rdgQwinstaEventId(d)?.let {
RdgQwinstaButton(
controller = qwinsta,
onClick = { qwinsta.openFor(d, scope) },
modifier = Modifier.padding(vertical = 4.dp),
)
}
if (eventSupportsSessionTerminate(d)) {
EventSessionTerminateButton(
enabled = !terminateLoading,
loading = terminateLoading,
onClick = { terminateConfirm = true },
modifier = Modifier.padding(vertical = 4.dp),
)
terminateMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
Field("Severity", d.severity)
Field(
"Тип",
if (isDailyReportType(d.type)) dailyReportTypeLabel(d.type) else d.type,
)
Field("Хост", d.hostname)
Field("Время", SacDateTimes.format(d.occurredAt, dateMode))
Field("Кратко", d.summary)
if (isDailyReportType(d.type)) {
reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) }
?: Field("Отчёт", "Полный текст отчёта недоступен")
} else {
EventDetailsSection(d.type, d.details, d.payload)
}
}
if (terminateConfirm) {
val d = data
AlertDialog(
onDismissRequest = { terminateConfirm = false },
title = { Text("Завершить сессию?") },
text = {
Text(
if (d != null) {
"Завершить сессию пользователя ${sessionTerminateLabel(d)}?"
} else {
"Завершить сессию пользователя?"
},
)
},
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
terminateConfirm = false
scope.launch {
terminateLoading = true
terminateMessage = null
try {
val res = repo.withAuth { it.postEventTerminateSession(id) }
if (!res.ok) {
terminateMessage = res.message
} else {
data = data?.copy(sessionTerminated = true)
}
} catch (e: Exception) {
terminateMessage = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminateLoading = false
}
}
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { terminateConfirm = false }) { Text("Отмена") }
},
)
}
RdgQwinstaDialog(qwinsta)
}
@Composable
private fun EventDetailsSection(type: String, details: JsonElement?, payload: JsonElement?) {
val formatted = formatEventDetails(type, details, payload)
if (formatted?.hasContent == true) {
Text(
"Детали",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp),
)
formatted.multilineText?.let { text ->
MultilineField(formatted.multilineLabel ?: "Сообщение", text)
}
formatted.fields.forEach { (label, value) -> Field(label, value) }
} else if (details != null) {
Field("Детали", details.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()
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
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, color = MaterialTheme.colorScheme.onSurface)
d.events.forEach { e ->
Text(
"${e.severity} · ${e.title}\n${SacDateTimes.format(e.occurredAt, dateMode)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
fun HostDetailScreen(
repo: SacRepository,
id: Long,
onOpenEvent: (Long) -> Unit,
onBack: () -> Unit,
) {
var data by remember { mutableStateOf<HostDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var selectedTab by remember { mutableIntStateOf(HostDetailTab.Info.ordinal) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
LaunchedEffect(id) {
loading = true
try {
data = repo.withAuth { it.getHost(id) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
Column(
Modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
Row(
Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 16.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = onBack)
Text(
data?.hostname ?: "Хост",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 4.dp),
)
}
error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (loading) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
} else {
val host = data ?: return@Column
TabRow(selectedTabIndex = selectedTab) {
HostDetailTab.entries.forEachIndexed { index, tab ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(tab.label) },
)
}
}
when (HostDetailTab.entries[selectedTab]) {
HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
HostDetailTab.Events -> HostEventsContent(
repo = repo,
hostId = host.id,
onOpenEvent = onOpenEvent,
)
}
}
}
}
@Composable
private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Field("Продукт", host.product)
Field("Версия", host.productVersion ?: "")
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
Field("Агент", host.agentStatus)
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
HostSessionsSection(repo = repo, hostId = host.id)
host.inventory?.let { inv ->
val lines = inventoryFieldLines(inv)
if (lines.isEmpty()) {
Field("Inventory", "")
} else {
Text("Железо и ПО", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
lines.forEach { (label, value) -> Field(label, value) }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HostEventsContent(
repo: SacRepository,
hostId: Long,
onOpenEvent: (Long) -> Unit,
) {
var page by remember { mutableIntStateOf(1) }
var items by remember { mutableStateOf<List<EventSummary>>(emptyList()) }
var total by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) refreshing = true else loading = true
error = null
try {
val res = repo.withAuth {
it.listEvents(
page = page,
pageSize = 30,
hostId = hostId,
includeHidden = true,
)
}
items = res.items
total = res.total
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(page, hostId) { load() }
Column(
Modifier
.fillMaxSize()
.padding(16.dp),
) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
if (items.isEmpty() && loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else {
LazyColumn {
items(items, key = { it.id }) { event ->
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onOpenEvent(event.id) },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp)) {
Text(
"${event.severity} · ${event.title}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
SacDateTimes.format(event.occurredAt, dateMode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = { if (page > 1) page-- }, enabled = page > 1)
Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface)
ForwardArrowButton(onClick = { page++ }, enabled = page * 30 < total)
}
}
}
@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 context = LocalContext.current
val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND)
val soundSettings by repo.session.notificationSoundSettings.collectAsStateWithLifecycle(
initialValue = NotificationSoundSettings(NotificationSoundSource.SYSTEM, null),
)
val dateTimeMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
val scope = rememberCoroutineScope()
val activity = context as? FragmentActivity
val latestSoundSettings by rememberUpdatedState(soundSettings)
suspend fun applySoundChannels(settings: NotificationSoundSettings) {
NotificationHelper.ensureChannels(
context,
NotificationSoundResolver.resolve(context, settings),
)
}
LaunchedEffect(soundSettings, notificationMode) {
if (notificationMode == NotificationMode.PUSH_SOUND) {
val normalized = repo.session.let {
NotificationSoundResolver.normalizeStoredSettings(context, it)
}
applySoundChannels(normalized)
}
}
fun openRingtonePicker() {
if (activity == null) {
Toast.makeText(context, "Не удалось открыть выбор звука", Toast.LENGTH_SHORT).show()
return
}
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
try {
RingtonePicker.launch(activity, currentUri) { uri ->
if (uri == null) {
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
} else {
val stored = NotificationSoundResolver.importCustomSound(context, uri)
if (stored == null) {
Toast.makeText(context, "Не удалось сохранить звук", Toast.LENGTH_LONG).show()
return@launch
}
repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, stored)
}
applySoundChannels(repo.session.getNotificationSoundSettings())
}
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, "На устройстве нет выбора звука уведомлений", Toast.LENGTH_LONG).show()
}
}
fun previewSound() {
RingtoneManager.getRingtone(context, NotificationSoundResolver.resolve(context, soundSettings))?.play()
}
LaunchedEffect(Unit) {
baseUrl = repo.session.getBaseUrl()
username = repo.session.getUsername()
role = repo.session.getRole()
deviceId = repo.session.getDeviceId()
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Моё устройство",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Field("Сервер", baseUrl ?: "")
Field("Пользователь", username ?: "")
Field("Роль", role ?: "")
Field("Device ID", deviceId.toString())
Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME)
Text(
stringResource(R.string.notification_mode_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
NotificationMode.entries.forEach { mode ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = notificationMode == mode,
onClick = {
scope.launch { repo.session.setNotificationMode(mode) }
},
)
Text(
mode.label,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
if (notificationMode == NotificationMode.PUSH_SOUND) {
Text(
stringResource(R.string.notification_sound_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
NotificationSoundSource.entries.forEach { source ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = soundSettings.source == source,
onClick = {
when (source) {
NotificationSoundSource.SYSTEM -> {
scope.launch {
repo.session.setNotificationSound(source, null)
applySoundChannels(repo.session.getNotificationSoundSettings())
}
}
NotificationSoundSource.CUSTOM -> {
if (soundSettings.customUri.isNullOrBlank()) {
openRingtonePicker()
} else {
scope.launch {
repo.session.setNotificationSound(source, soundSettings.customUri)
applySoundChannels(repo.session.getNotificationSoundSettings())
}
}
}
}
},
)
Text(
source.label,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Text(
stringResource(
R.string.notification_sound_current,
NotificationSoundResolver.displayName(context, soundSettings),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (soundSettings.source == NotificationSoundSource.CUSTOM) {
Button(onClick = { openRingtonePicker() }) {
Text(stringResource(R.string.notification_sound_pick))
}
}
Button(onClick = { previewSound() }) {
Text(stringResource(R.string.notification_sound_preview))
}
}
}
Text(
stringResource(R.string.date_time_format_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
DateTimeDisplayMode.entries.forEach { mode ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = dateTimeMode == mode,
onClick = {
scope.launch { repo.session.setDateTimeDisplayMode(mode) }
},
)
Text(
mode.label,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
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)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
BackArrowButton(onClick = onBack)
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 4.dp),
)
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else {
content()
}
}
}
@Composable
private fun MultilineField(label: String, value: String) {
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun Field(label: String, value: String) {
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
}
private fun JsonElement.toPrettyString(): String = toString()
@@ -0,0 +1,577 @@
package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.Card
import androidx.compose.material3.CardDefaults
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.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.launch
import retrofit2.HttpException
import ru.kalinamall.seaca.data.DateTimeDisplayMode
import ru.kalinamall.seaca.data.DashboardSummary
import ru.kalinamall.seaca.data.EventListResponse
import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.HostSummary
import ru.kalinamall.seaca.data.ProblemSummary
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
import ru.kalinamall.seaca.ui.components.RdgAccessBadge
import ru.kalinamall.seaca.ui.components.RdgFlapBadge
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate
import ru.kalinamall.seaca.ui.util.sessionTerminateLabel
private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов",
"report.daily.rdp" to "Отчёты RDP клиентов",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
var data by remember { mutableStateOf<DashboardSummary?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(false) }
var refreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
val scrollState = rememberScrollState()
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) {
refreshing = true
} else {
loading = true
}
error = null
try {
data = repo.withAuth { it.dashboardSummary() }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(refreshKey) { load() }
LifecycleResumeEffect(refreshKey) {
load(showRefreshIndicator = data != null)
onPauseOrDispose { }
}
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.fillMaxSize(),
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.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,
color = MaterialTheme.colorScheme.onSurface,
)
d.recentEvents.take(8).forEach { e ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!e.rdgAccessPath.isNullOrBlank()) {
RdgAccessBadge(path = e.rdgAccessPath)
}
if (hasRdgFlapUi(e)) {
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
}
Text(
dashboardRecentEventLine(e),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
rdgQwinstaEventId(e)?.let {
RdgQwinstaButton(
controller = qwinsta,
onClick = { qwinsta.openFor(e, scope) },
)
}
}
Text(
SacDateTimes.format(e.occurredAt, dateMode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (data == null && loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
}
RdgQwinstaDialog(qwinsta)
}
private fun dashboardRecentEventLine(event: EventSummary): String {
val body = if (event.type.startsWith("agent.")) {
"${event.hostname}: ${event.title}"
} else {
event.title
}
return "${event.severity}: $body"
}
@Composable
private fun StatCard(label: String, value: String) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var hostname by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
var terminateLoadingId by remember { mutableStateOf<Long?>(null) }
var terminateConfirm by remember { mutableStateOf<EventSummary?>(null) }
var terminateError by remember { mutableStateOf<String?>(null) }
var locallyTerminatedIds by remember { mutableStateOf(setOf<Long>()) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
terminateConfirm?.let { event ->
AlertDialog(
onDismissRequest = { terminateConfirm = null },
title = { Text("Завершить сессию?") },
text = { Text("Завершить сессию пользователя ${sessionTerminateLabel(event)}?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val target = event
terminateConfirm = null
scope.launch {
terminateLoadingId = target.id
terminateError = null
try {
val res = repo.withAuth { it.postEventTerminateSession(target.id) }
if (!res.ok) {
terminateError = res.message
} else {
locallyTerminatedIds = locallyTerminatedIds + target.id
}
} catch (e: Exception) {
terminateError = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminateLoadingId = null
}
}
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { terminateConfirm = null }) { Text("Отмена") }
},
)
}
PagedListScreen(
title = "События",
loadPage = { page, severity, host ->
val res = repo.withAuth {
it.listEvents(
page = page,
pageSize = 30,
severity = severity,
hostname = host,
)
}
res.items to res.total
},
itemKey = EventSummary::id,
itemLabel = { "${it.severity} · ${it.title}" },
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
itemRdgAccessPath = { it.rdgAccessPath },
itemRdgFlap = { hasRdgFlapUi(it) },
itemRdgFlapPairId = { it.rdgFlapPairEventId },
itemQwinsta = { rdgQwinstaEventId(it) != null },
onQwinsta = { qwinsta.openFor(it, scope) },
qwinstaLoading = qwinsta.loading,
itemTerminate = {
eventSupportsSessionTerminate(it) && it.id !in locallyTerminatedIds
},
onTerminate = { terminateConfirm = it },
terminateLoadingId = terminateLoadingId,
onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"),
hostnameFilter = hostname,
headerContent = {
terminateError?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField(
value = hostname,
onValueChange = { hostname = it },
label = { Text("Хост") },
placeholder = { Text("Фильтр по имени хоста") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
},
)
RdgQwinstaDialog(qwinsta)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen(
title = "Проблемы",
loadPage = { page, filter, _ ->
val res = repo.withAuth {
it.listProblems(page = page, pageSize = 30, status = filter ?: "open")
}
res.items to res.total
},
itemKey = ProblemSummary::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, _, _ ->
val res = repo.withAuth { it.listHosts(page = page, pageSize = 30) }
res.items to res.total
},
itemKey = HostSummary::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 refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) refreshing = true else loading = true
try {
data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(page, reportType) { load() }
val reportTypeLabel = REPORT_TYPE_OPTIONS.firstOrNull { it.first == reportType }?.second ?: reportType
Column(Modifier.fillMaxSize().padding(16.dp)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
readOnly = true,
value = reportTypeLabel,
onValueChange = {},
label = { Text("Тип отчёта") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
REPORT_TYPE_OPTIONS.forEach { (value, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = { reportType = value; expanded = false; page = 1 },
)
}
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
LazyColumn {
items(data?.items.orEmpty(), key = { it.id }) { item ->
ListRow(
title = item.summary,
subtitle = "${item.hostname} · ${SacDateTimes.format(item.occurredAt, dateMode)}",
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?, String?) -> Pair<List<T>, Int>,
itemKey: (T) -> Long,
itemLabel: (T) -> String,
itemSub: (T) -> String,
onOpen: (Long) -> Unit,
severities: List<String?>,
itemRdgAccessPath: ((T) -> String?)? = null,
itemRdgFlap: ((T) -> Boolean)? = null,
itemRdgFlapPairId: ((T) -> Long?)? = null,
itemQwinsta: ((T) -> Boolean)? = null,
onQwinsta: ((T) -> Unit)? = null,
qwinstaLoading: Boolean = false,
itemTerminate: ((T) -> Boolean)? = null,
onTerminate: ((T) -> Unit)? = null,
terminateLoadingId: Long? = null,
filterLabel: String = "Severity",
hostnameFilter: String = "",
headerContent: @Composable () -> Unit = {},
) {
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 refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val hostQuery = hostnameFilter.trim().ifBlank { null }
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) refreshing = true else loading = true
try {
val (pageItems, pageTotal) = loadPage(page, filter, hostQuery)
items = pageItems
total = pageTotal
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(hostQuery) {
if (page != 1) page = 1
}
LaunchedEffect(page, filter, hostQuery) { load() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
headerContent()
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 = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
LazyColumn {
items(items, key = { itemKey(it) }) { item ->
ListRow(
title = itemLabel(item),
subtitle = itemSub(item),
onClick = { onOpen(itemKey(item)) },
rdgAccessPath = itemRdgAccessPath?.invoke(item),
rdgFlap = itemRdgFlap?.invoke(item) == true,
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
showQwinsta = itemQwinsta?.invoke(item) == true,
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
qwinstaLoading = qwinstaLoading,
showTerminate = itemTerminate?.invoke(item) == true,
onTerminateClick = onTerminate?.let { handler -> { handler(item) } },
terminateLoading = terminateLoadingId == 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,
rdgAccessPath: String? = null,
rdgFlap: Boolean = false,
rdgFlapPairId: Long? = null,
showQwinsta: Boolean = false,
onQwinstaClick: (() -> Unit)? = null,
qwinstaLoading: Boolean = false,
showTerminate: Boolean = false,
onTerminateClick: (() -> Unit)? = null,
terminateLoading: Boolean = false,
) {
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!rdgAccessPath.isNullOrBlank()) {
RdgAccessBadge(path = rdgAccessPath)
}
if (rdgFlap) {
RdgFlapBadge(pairEventId = rdgFlapPairId)
}
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (showQwinsta && onQwinstaClick != null) {
OutlinedButton(
enabled = !qwinstaLoading,
onClick = onQwinstaClick,
) { Text(if (qwinstaLoading) "" else "Оборвать сессию") }
}
if (showTerminate && onTerminateClick != null) {
EventSessionTerminateButton(
enabled = !terminateLoading,
loading = terminateLoading,
onClick = onTerminateClick,
)
}
}
}
}
@Composable
private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = onPrev, enabled = page > 1)
Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface)
ForwardArrowButton(onClick = onNext, enabled = page * pageSize < total)
}
}
@@ -0,0 +1,44 @@
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),
onPrimary = Color(0xFF002A42),
background = Color(0xFF0F1419),
onBackground = Color(0xFFC5D0DC),
surface = Color(0xFF15202B),
onSurface = Color(0xFFE8EEF4),
surfaceContainer = Color(0xFF1C2834),
surfaceContainerLow = Color(0xFF182330),
surfaceContainerHigh = Color(0xFF243240),
onSurfaceVariant = Color(0xFFB0BEC9),
error = Color(0xFFFF8A8A),
)
private val SacLight = lightColorScheme(
primary = Color(0xFF1A6FA0),
onPrimary = Color(0xFFFFFFFF),
background = Color(0xFFF4F6F8),
onBackground = Color(0xFF1A1F24),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1F24),
surfaceContainer = Color(0xFFEEF2F5),
surfaceContainerLow = Color(0xFFF4F6F8),
surfaceContainerHigh = Color(0xFFE4E9ED),
onSurfaceVariant = Color(0xFF4A5560),
)
@Composable
fun SeacaTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
MaterialTheme(
colorScheme = if (dark) SacDark else SacLight,
content = content,
)
}
@@ -0,0 +1,359 @@
package ru.kalinamall.seaca.ui.util
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class FormattedEventDetails(
val fields: List<Pair<String, String>> = emptyList(),
val multilineLabel: String? = null,
val multilineText: String? = null,
) {
val hasContent: Boolean
get() = fields.isNotEmpty() || !multilineText.isNullOrBlank()
}
fun isDailyReportType(type: String): Boolean =
type == "report.daily.ssh" || type == "report.daily.rdp"
fun dailyReportTypeLabel(type: String): String = when (type) {
"report.daily.ssh" -> "Отчёты ssh клиентов"
"report.daily.rdp" -> "Отчёты RDP клиентов"
else -> type
}
private fun isLoginEventType(type: String): Boolean =
type.startsWith("rdp.login.") ||
type.startsWith("ssh.login.") ||
type.startsWith("agent.login.")
/** Текст ежедневного отчёта из details (не сырой JSON). */
fun reportTextFromDetails(details: JsonElement?): String? {
if (details == null) return null
val obj = details as? JsonObject ?: return null
val body = obj["report_body"]?.jsonPrimitive?.contentOrNull?.trim()
if (!body.isNullOrEmpty()) {
return normalizeReportPlainText(body)
}
val html = obj["report_html"]?.jsonPrimitive?.contentOrNull?.trim()
if (!html.isNullOrEmpty()) {
return normalizeReportPlainText(stripHtmlTags(html))
}
return null
}
fun normalizeReportPlainText(body: String): String =
body
.replace("\r\n", "\n")
.replace("\\n", "\n")
.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
.trim()
private fun stripHtmlTags(html: String): String =
html
.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.trim()
fun formatEventDetails(
type: String,
details: JsonElement?,
payload: JsonElement? = null,
): FormattedEventDetails? {
val obj = details as? JsonObject ?: return null
return when (type) {
"agent.lifecycle" -> formatLifecycleDetails(obj, payload)
"agent.inventory" -> formatInventoryEventDetails(obj)
"rdp.bruteforce.burst" -> formatBruteforceBurstDetails(obj)
else -> when {
isLoginEventType(type) -> formatLoginDetails(obj)
else -> {
val fields = extractStructuredDetailFields(obj)
if (fields.isEmpty()) null else FormattedEventDetails(fields = fields)
}
}
}
}
private fun formatLifecycleDetails(obj: JsonObject, payload: JsonElement?): FormattedEventDetails {
jsonString(obj, "notification_body")?.let { body ->
return FormattedEventDetails(
multilineLabel = "Сообщение",
multilineText = normalizeReportPlainText(body),
)
}
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "lifecycle")?.let { fields += "Событие" to lifecycleLabel(it) }
jsonString(obj, "trigger")?.let { fields += "Причина" to lifecycleTriggerLabel(it) }
sourceProductLine(payload)?.let { fields += it }
return FormattedEventDetails(fields = fields)
}
private fun formatInventoryEventDetails(obj: JsonObject): FormattedEventDetails {
val changes = obj["hardware_changes"]?.jsonArray
if (changes != null && changes.isNotEmpty()) {
val fields = changes.mapIndexedNotNull { index, element ->
val change = element.jsonObject
val field = jsonString(change, "field") ?: return@mapIndexedNotNull null
val old = primitiveDisplay(change["old"]) ?: ""
val new = primitiveDisplay(change["new"]) ?: ""
"Изменение ${index + 1}" to "${hardwareFieldLabel(field)}: $old$new"
}
return FormattedEventDetails(fields = fields)
}
val inventory = obj["inventory"]
if (inventory != null) {
val lines = inventoryFieldLines(inventory)
if (lines.isNotEmpty()) return FormattedEventDetails(fields = lines)
}
val fallback = extractStructuredDetailFields(obj)
return if (fallback.isEmpty()) FormattedEventDetails() else FormattedEventDetails(fields = fallback)
}
private fun formatBruteforceBurstDetails(obj: JsonObject): FormattedEventDetails {
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "tier")?.let { fields += "Уровень" to it }
primitiveDisplay(obj["count"])?.let { fields += "Количество" to it }
jsonString(obj, "bucket_key")?.let { fields += "Ключ" to it }
jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it }
jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it }
if (fields.isEmpty()) {
return FormattedEventDetails(fields = extractStructuredDetailFields(obj))
}
return FormattedEventDetails(fields = fields)
}
private fun formatLoginDetails(obj: JsonObject): FormattedEventDetails {
jsonString(obj, "notification_body")?.let { body ->
return FormattedEventDetails(
multilineLabel = "Сообщение",
multilineText = normalizeReportPlainText(body),
)
}
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it }
jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it }
jsonString(obj, "workstation_name", "computer_name")?.let { fields += "Рабочая станция" to it }
jsonString(obj, "process_name", "process")?.let { fields += "Процесс" to it }
jsonPrimitiveString(obj, "logon_type")?.let { fields += "Тип входа" to logonTypeLabel(it) }
jsonPrimitiveString(obj, "port")?.let { fields += "Порт" to it }
val attempt = jsonPrimitiveString(obj, "attempt_number")
val maxAttempts = jsonPrimitiveString(obj, "max_attempts")
if (attempt != null && maxAttempts != null) {
fields += "Попытка" to "$attempt / $maxAttempts"
}
jsonPrimitiveString(obj, "event_id_windows")?.let { fields += "Event ID Windows" to it }
if (fields.isEmpty()) {
return FormattedEventDetails(fields = extractStructuredDetailFields(obj))
}
return FormattedEventDetails(fields = fields)
}
private fun extractStructuredDetailFields(obj: JsonObject): List<Pair<String, String>> {
val fields = mutableListOf<Pair<String, String>>()
val consumed = mutableSetOf<String>()
fun add(label: String, vararg keys: String) {
jsonString(obj, *keys)?.let {
fields += label to it
keys.forEach { key -> consumed += key }
}
jsonPrimitiveString(obj, *keys)?.let {
fields += label to it
keys.forEach { key -> consumed += key }
}
}
add("Пользователь", "user", "username", "shadower_user", "target_user")
add("IP", "ip_address", "source_ip", "ip", "external_ip", "internal_ip")
add("Рабочая станция", "workstation_name", "computer_name")
add("Процесс", "process_name", "process")
add("Порт", "port")
add("Команда", "command")
add("Share", "share_name")
add("Путь", "share_path")
add("ResourceUri", "resource_uri")
add("Session ID", "session_id", "target_session_id")
add("Event ID Windows", "event_id_windows")
add("Уровень", "tier", "risk_level")
add("Количество", "count")
add("Ключ", "bucket_key")
jsonPrimitiveString(obj, "logon_type")?.let {
fields += "Тип входа" to logonTypeLabel(it)
consumed += "logon_type"
}
obj.forEach { (key, value) ->
if (key in consumed || key in SKIP_DETAIL_KEYS) return@forEach
if (value is JsonPrimitive) {
val text = primitiveDisplay(value) ?: return@forEach
fields += detailKeyLabel(key) to text
}
}
return fields
}
private val SKIP_DETAIL_KEYS = setOf(
"telegram_via",
"generated_by",
"report_body",
"report_html",
"inventory",
"hardware_changes",
"notification_body",
"lifecycle",
"trigger",
)
private fun detailKeyLabel(key: String): String = when (key) {
"user" -> "Пользователь"
"username" -> "Пользователь"
"source_ip" -> "IP"
"ip_address" -> "IP"
"workstation_name" -> "Рабочая станция"
"event_id_windows" -> "Event ID Windows"
"logon_type" -> "Тип входа"
"attempt_number" -> "Попытка"
"max_attempts" -> "Макс. попыток"
else -> key.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
private fun lifecycleLabel(value: String): String = when (value.lowercase()) {
"started" -> "Запущен"
"stopped" -> "Остановлен"
"settings_reloaded" -> "Настройки перечитаны"
else -> value
}
private fun lifecycleTriggerLabel(value: String): String = when (value.lowercase()) {
"boot" -> "Загрузка ОС / планировщик"
"deploy_recycle" -> "Обновление скрипта (recycle)"
"settings_reload" -> "Graceful restart (settings)"
"manual_recycle" -> "Ручной recycle"
"shutdown" -> "Остановка процесса"
else -> value
}
private fun logonTypeLabel(raw: String): String {
val code = raw.trim().toIntOrNull()
val name = when (code) {
2 -> "Interactive"
3 -> "Network"
4 -> "Batch"
5 -> "Service"
7 -> "Unlock"
8 -> "NetworkCleartext"
9 -> "NewCredentials"
10 -> "RemoteInteractive"
11 -> "CachedInteractive"
else -> null
}
return when {
name != null && code != null -> "$name ($code)"
else -> raw
}
}
private fun hardwareFieldLabel(field: String): String = when (field) {
"memory_gb" -> "Память (GB)"
"processor" -> "Процессор"
else -> field
}
private fun sourceProductLine(payload: JsonElement?): Pair<String, String>? {
val source = payload?.jsonObject?.get("source")?.jsonObject ?: return null
val product = jsonString(source, "product")
val version = jsonString(source, "product_version")
val line = listOfNotNull(product, version).joinToString(" ").trim()
return if (line.isEmpty()) null else "Агент" to line
}
private fun jsonString(obj: JsonObject, vararg keys: String): String? {
for (key in keys) {
val value = obj[key]?.jsonPrimitive?.contentOrNull?.trim()
if (!value.isNullOrEmpty()) return value
}
return null
}
private fun jsonPrimitiveString(obj: JsonObject, vararg keys: String): String? {
for (key in keys) {
val el = obj[key] ?: continue
primitiveDisplay(el)?.let { return it }
}
return null
}
private fun primitiveDisplay(el: JsonElement?): String? =
when (el) {
is JsonPrimitive -> el.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
else -> null
}
private fun jsonInt(obj: JsonObject, key: String): Int? =
obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
/** Плоский список полей инвентаризации для карточки хоста / agent.inventory. */
fun inventoryFieldLines(inventory: JsonElement): List<Pair<String, String>> {
val obj = inventory as? JsonObject ?: return emptyList()
val out = mutableListOf<Pair<String, String>>()
jsonString(obj, "computer_name")?.let { out += "Компьютер" to it }
jsonInt(obj, "memory_gb")?.let { out += "Память" to "$it GB" }
obj["windows"]?.jsonObject?.let { w ->
listOf("product_name" to "Продукт", "version" to "Версия", "os_hardware_abstraction_layer" to "HAL")
.forEach { (key, label) ->
jsonString(w, key)?.let { out += label to it }
}
}
obj["motherboard"]?.jsonObject?.let { m ->
val line = listOfNotNull(jsonString(m, "manufacturer"), jsonString(m, "product"))
.joinToString(" ")
.trim()
if (line.isNotEmpty()) out += "Материнская плата" to line
}
obj["processor"]?.jsonArray?.forEachIndexed { i, el ->
val p = el.jsonObject
val name = jsonString(p, "name") ?: return@forEachIndexed
val cores = jsonInt(p, "cores")
val logical = jsonInt(p, "logical_processors")
val suffix = when {
cores != null && logical != null -> "$cores ядер / $logical потоков"
cores != null -> "$cores ядер"
else -> ""
}
out += "Процессор ${i + 1}" to "$name$suffix"
}
obj["disks"]?.jsonArray?.forEachIndexed { i, el ->
val d = el.jsonObject
val name = jsonString(d, "friendly_name") ?: ""
val media = jsonString(d, "media_type") ?: ""
val size = jsonInt(d, "size_gb")?.toString() ?: ""
out += "Диск ${i + 1}" to "$name · $media · ${size} GB"
}
obj["video"]?.jsonArray?.forEachIndexed { i, el ->
jsonString(el.jsonObject, "name")?.let { out += "Видео ${i + 1}" to it }
}
obj["ipv4"]?.jsonArray?.let { arr ->
val ips = arr.mapNotNull { it.jsonPrimitive.contentOrNull?.trim() }.filter { it.isNotEmpty() }
if (ips.isNotEmpty()) out += "IPv4" to ips.joinToString(", ")
}
return out
}
@@ -0,0 +1,88 @@
package ru.kalinamall.seaca.ui.util
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.EventSummary
data class QwinstaSessionRow(
val sessionName: String,
val userName: String,
val id: Int,
val state: String,
)
fun hasRdgFlapUi(event: EventSummary): Boolean =
event.rdgFlap || event.rdgFlapQwinstaEventId != null
fun hasRdgFlapUi(event: EventDetail): Boolean =
event.rdgFlap || event.rdgFlapQwinstaEventId != null
fun hasRdgAccessPath(event: EventSummary): Boolean =
!event.rdgAccessPath.isNullOrBlank()
fun hasRdgAccessPath(event: EventDetail): Boolean =
!event.rdgAccessPath.isNullOrBlank()
/** Как SAC rdgQwinstaEventId: кнопка только при rdg_qwinsta_enabled от API. */
fun rdgQwinstaEventId(event: EventSummary): Long? {
if (!event.rdgQwinstaEnabled) return null
return event.rdgFlapQwinstaEventId ?: event.id
}
fun rdgQwinstaEventId(event: EventDetail): Long? {
if (!event.rdgQwinstaEnabled) return null
return event.rdgFlapQwinstaEventId ?: event.id
}
/** @deprecated use rdgQwinstaEventId */
fun rdgFlapQwinstaEventId(event: EventSummary): Long? = rdgQwinstaEventId(event)
/** @deprecated use rdgQwinstaEventId */
fun rdgFlapQwinstaEventId(event: EventDetail): Long? = rdgQwinstaEventId(event)
fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessionRow> {
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
val rows = mutableListOf<QwinstaSessionRow>()
for (line in lines) {
if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue
val parts = line.split(Regex("\\s+"))
if (parts.size < 4) continue
val id = parts[2].toIntOrNull() ?: continue
val userName = parts[1]
if (!actorUser.isNullOrBlank()) {
val norm: (String) -> String = { it.replace(Regex("^B26\\\\", RegexOption.IGNORE_CASE), "").lowercase() }
if (!norm(userName).contains(norm(actorUser))) continue
}
rows.add(
QwinstaSessionRow(
sessionName = parts[0].removePrefix(">").trim(),
userName = userName,
id = id,
state = parts.drop(3).joinToString(" "),
),
)
}
if (rows.isEmpty() && stdout.isNotBlank()) {
for (line in lines) {
if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue
val parts = line.split(Regex("\\s+"))
if (parts.size < 4) continue
val id = parts[2].toIntOrNull() ?: continue
rows.add(
QwinstaSessionRow(
sessionName = parts[0].removePrefix(">").trim(),
userName = parts[1],
id = id,
state = parts.drop(3).joinToString(" "),
),
)
}
}
return rows
}
fun agentCommandError(cmd: ru.kalinamall.seaca.data.AgentCommandResponse): String? {
if (cmd.status != "failed") return null
return cmd.resultStderr?.takeIf { it.isNotBlank() }
?: cmd.resultStdout?.takeIf { it.isNotBlank() }
?: "Команда завершилась с ошибкой"
}
@@ -0,0 +1,46 @@
package ru.kalinamall.seaca.ui.util
import ru.kalinamall.seaca.data.DateTimeDisplayMode
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
object SacDateTimes {
fun format(raw: String?, mode: DateTimeDisplayMode): String {
if (raw.isNullOrBlank()) return ""
val zoned = parse(raw) ?: return raw.trim()
val local = zoned.withZoneSameInstant(ZoneId.systemDefault())
return when (mode) {
DateTimeDisplayMode.LOCALE -> {
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
.format(local)
}
else -> {
val pattern = mode.pattern ?: DateTimeDisplayMode.SLASH.pattern!!
DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).format(local)
}
}
}
private fun parse(raw: String): ZonedDateTime? {
val trimmed = raw.trim()
val normalised = trimmed.replace(Regex(" ([+-]\\d{2}:\\d{2})$"), "$1")
val candidates = listOf(normalised, trimmed)
for (candidate in candidates) {
runCatching { return OffsetDateTime.parse(candidate).toZonedDateTime() }
runCatching { return ZonedDateTime.parse(candidate) }
runCatching { return Instant.parse(candidate).atZone(ZoneId.systemDefault()) }
runCatching {
return LocalDateTime.parse(candidate, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.atZone(ZoneId.systemDefault())
}
}
return null
}
}
@@ -0,0 +1,26 @@
package ru.kalinamall.seaca.ui.util
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.EventSummary
private val LINUX_SESSION_EVENT_TYPES = setOf(
"session.logind.new",
"ssh.login.success",
"privilege.sudo.command",
)
private val WINDOWS_SESSION_EVENT_TYPES = setOf("rdp.login.success")
fun eventSupportsSessionTerminate(event: EventSummary): Boolean =
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
!event.sessionTerminated
fun eventSupportsSessionTerminate(event: EventDetail): Boolean =
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
!event.sessionTerminated
fun sessionTerminateLabel(event: EventSummary): String =
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
fun sessionTerminateLabel(event: EventDetail): String =
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
@@ -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>
+16
View File
@@ -0,0 +1,16 @@
<?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>
<string name="channel_default_silent">SAC уведомления (без звука)</string>
<string name="channel_high_silent">SAC: важные (без звука)</string>
<string name="channel_critical_silent">SAC: критические (без звука)</string>
<string name="notification_mode_title">Push-уведомления</string>
<string name="date_time_format_title">Формат даты и времени</string>
<string name="notification_sound_title">Звук уведомлений</string>
<string name="notification_sound_pick">Выбрать звук</string>
<string name="notification_sound_preview">Проверить звук</string>
<string name="notification_sound_current">Сейчас: %1$s</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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="notification_sounds" path="." />
</paths>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
+7
View File
@@ -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
}
+112
View File
@@ -0,0 +1,112 @@
# Roadmap — Seaca
Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в [security-alert-center](https://git.papatramp.ru/PapaTramp/security-alert-center), фаза **v0.6 — Mobile**.
---
## Принятые решения
| Тема | Решение |
|------|---------|
| UI | Kotlin + Jetpack Compose (нативно) |
| Push | FCM (бесплатный канал доставки) |
| Привязка | Код регистрации от администратора (+ URL сервера; QR опционально позже) |
| Оповещения | Те же правила SAC: `min_severity`, cooldown, dedup — канал `push_mobile` в policy |
| Админка устройств | Только веб-SAC: список, отзыв, способ логина, выдача кодов |
| Роли | `viewer` — просмотр; `operator` — ack/resolve; admin-функции — не в приложении |
---
## v0.1 — Каркас приложения ✅
- [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 (чтение) ✅
- [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 — Действия оператора ✅
- [x] `POST /api/v1/problems/{id}/ack` и `/resolve`
- [x] Обновление карточки после действия
- [x] Admin-only API не вызываем из приложения
- [x] Биометрический замок при возврате в приложение
- [x] «Доверять сертификату» для внутреннего TLS (self-signed)
---
## v0.4 — Push (FCM) ✅ (частично)
- [x] Firebase-проект, конфиг в app/ (не в git; шаблон `*.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)
- [x] RDG flap: qwinsta/logoff через SAC API (карточка события)
- [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
---
## v0.5 — Полировка ✅ (основное)
- [x] Refresh token rotation (`POST /api/v1/mobile/auth/refresh`) — уже в v0.1
- [x] Биометрия при **запуске** и при возврате в приложение
- [x] Pull-to-refresh на обзоре и в списках
- [x] Формат даты/времени (несколько пресетов + системный локаль)
- [x] Режимы push на устройстве: звук / без звука / выкл.
- [x] Звук push: системный или пользовательский (ringtone picker)
- [x] Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки, «Выйти»
- [ ] Виджет: число open problems
- [ ] Offline-кэш последних N событий (Room)
- [ ] Минимальная версия приложения (проверка с сервера)
---
## Сервер SAC (отдельный репозиторий) — чеклист v0.6
Эти пункты реализуются в **security-alert-center**, не в Seaca:
- [x] `mobile_settings`: `devices_allowed`, max devices, min app version
- [x] Таблицы `mobile_devices`, `mobile_enrollment_codes`, `mobile_refresh_tokens`
- [x] Веб: **Настройки → Мобильные устройства** — вкл/выкл, коды, список, отзыв, способ логина
- [x] API enrollment: создать код (admin), погасить, список устройств, revoke, test push
- [x] JWT refresh + привязка к `device_id`
- [x] FCM HTTP v1 из worker (`SAC_FCM_*` в `sac-api.env`)
- [ ] Audit: выдача кода, enroll, revoke, смена FCM
---
## Не в плане (пока)
- iOS
- UnifiedPush без FCM
- Управление пользователями SAC из телефона
- Настройки Telegram/SMTP с телефона
- Несколько SAC-серверов в одном приложении
---
## KPI
- От события на SAC до push на телефон: p95 &lt; 45 с (как Telegram)
- Pairing по коду: &lt; 2 минут для оператора
- Отзыв устройства: push прекращается в течение 1 минуты
+39
View File
@@ -0,0 +1,39 @@
# Seaca — оператор
## Подключение
URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату.
## Разделы
| Раздел | Содержание |
|--------|------------|
| Обзор | Сводка 24 ч; pull-to-refresh |
| События | Лента; **RDG flap** в заголовке |
| Проблемы | Ack / Resolve (monitor+) |
| Хосты | Агенты, heartbeat |
| Отчёты | `report.daily.ssh` / `report.daily.rdp` |
| Моё устройство | Push, звук, дата, выход |
## RDG flap — qwinsta / logoff
Пара RD Gateway **302→303** за несколько секунд:
1. Событие **302** или **303** (метка **RDG flap**).
2. **qwinsta / logoff**`POST /api/v1/events/{id}/actions/qwinsta` на SAC.
3. SAC: WinRM **qwinsta** на клиентский ПК (`internal_ip`).
4. В диалоге — сессии, **logoff** по ID.
Нужен **Windows domain admin** в настройках SAC (WinRM).
## Push
Режимы на экране «Моё устройство». Тап — карточка события или problem.
## Блокировка
Биометрия/PIN при запуске и возврате в приложение.
## Потеря доступа
Новый код или снятие отзыва устройства в веб-SAC.
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -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
Vendored
+92
View File
@@ -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
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Перед push на github: нет internal URL и файлов других зеркал.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
PATTERNS=(
'git\.kalinamall\.ru'
'git\.papatramp\.ru'
'papatramp\.lan'
'192\.168\.'
'kalinamall/main'
'push.*kalinamall'
)
scan_files() {
local hits=0
local files
files=$(git ls-files)
while read -r f; do
[[ -z $f ]] && continue
[[ -f $f ]] || continue
case $f in
scripts/check-github-clean.sh | scripts/push-remotes.sh) continue ;;
esac
for pat in "${PATTERNS[@]}"; do
if grep -qE "$pat" "$f" 2>/dev/null; then
echo " INTERNAL? $f (pattern: $pat)"
hits=$((hits + 1))
fi
done
done <<<"$files"
return $hits
}
echo "[check-github-clean] сканирование tracked-файлов"
n=0
scan_files || n=$?
if [[ $n -gt 0 ]]; then
echo "[check-github-clean] найдено: $n"
exit 1
fi
for f in scripts/readme/kalinamall.md scripts/readme/home.md; do
if [[ -f $f ]]; then
echo " FORBIDDEN FILE: $f"
exit 1
fi
done
echo "[check-github-clean] OK"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Синхронизация main на kalinamall / home / github: свой README и только свои readme-шаблоны.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
CHECK_SECRETS="${CHECK_SECRETS:-$HOME/Documents/Cursor/Projects/Answer.and.other.shit/guides/init.new.comps/scripts/check-no-secrets.sh}"
CHECK_GITHUB="${CHECK_GITHUB:-$ROOT/scripts/check-github-clean.sh}"
sync_remote() {
local remote=$1
local readme_template=$2
local branch="sync-${remote}"
shift 2
local -a remove=( "$@" )
git fetch "$remote"
git checkout -B "$branch" "${remote}/main"
git checkout main -- .
cp "$readme_template" README.md
for f in "${remove[@]}"; do
if [[ -e $f ]]; then
rm -rf "$f"
fi
done
rm -rf .cursor .cursorignore
git add -u .
git add README.md
if git diff --cached --quiet; then
echo "[push-remotes] $remote: без изменений"
else
git commit -m "chore: sync $remote (README и файлы зеркала)"
fi
if [[ $remote == github ]]; then
if [[ -f $CHECK_GITHUB ]]; then
bash "$CHECK_GITHUB"
fi
if [[ -f $CHECK_SECRETS ]]; then
bash "$CHECK_SECRETS" github
fi
fi
git push "$remote" "${branch}:main"
git checkout main
git branch -D "$branch"
}
echo "[push-remotes] kalinamall"
sync_remote kalinamall "$ROOT/scripts/readme/kalinamall.md" \
scripts/readme/github.md \
scripts/readme/home.md
echo "[push-remotes] home"
sync_remote home "$ROOT/scripts/readme/home.md" \
scripts/readme/github.md \
scripts/readme/kalinamall.md \
scripts/push-remotes.sh \
scripts/check-github-clean.sh
echo "[push-remotes] github"
sync_remote github "$ROOT/scripts/readme/github.md" \
scripts/readme/kalinamall.md \
scripts/readme/home.md \
scripts/push-remotes.sh \
scripts/check-github-clean.sh
cp "$ROOT/scripts/readme/kalinamall.md" README.md
git add README.md
if ! git diff --cached --quiet; then
git commit -m "docs: README kalinamall (рабочая копия main)"
fi
echo "[push-remotes] готово."
+68
View File
@@ -0,0 +1,68 @@
## Стек
| Слой | Технология |
|------|------------|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Сеть | Retrofit / OkHttp, Kotlin Serialization |
| Хранение | DataStore + Android Keystore (токены, URL сервера) |
| Push | **Firebase Cloud Messaging (FCM)** |
| Минимум SDK | Android 8.0 (API 26) |
## Что есть в приложении
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
- Pull-to-refresh на обзоре и в списках
- Фильтры событий и проблем (severity, status)
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
- Выбор звука уведомления: системный или свой (ringtone picker)
- Биометрическая блокировка при **запуске** и при возврате в приложение
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб)
- Управление пользователями SAC
- Настройки каналов (Telegram, SMTP, webhook), severity, policy
- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
## Привязка телефона к серверу
1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии).
4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
## Статус
**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты)
## Документация
| Документ | Назначение |
|----------|------------|
| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
```bash
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
## Лицензия
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Нет в приложении (только веб-SAC)
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
## Привязка
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
[docs/operator-guide.md](docs/operator-guide.md)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Related repositories
| Repository | URL |
|------------|-----|
| seaca | https://git.papatramp.ru/PapaTramp/seaca |
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/RDP-login-monitor |
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Нет в приложении (только веб-SAC)
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
## Привязка
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
[docs/operator-guide.md](docs/operator-guide.md)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Репозитории
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.papatramp.ru/PapaTramp/seaca |
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/rdp-login-monitor |
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Нет в приложении (только веб-SAC)
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
## Привязка
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
[docs/operator-guide.md](docs/operator-guide.md)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Репозитории
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.papatramp.ru/PapaTramp/seaca |
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/rdp-login-monitor |
+8
View File
@@ -0,0 +1,8 @@
# Скопируйте в secrets.properties в корне репозитория (рядом с gradlew.bat).
# Пароль — только ASCII (латиница, цифры, символы). Store и key — один пароль.
# seaca-release.jks — тот же файл на рабочем и домашнем ПК (не в git).
SEACA_STORE_FILE=seaca-release.jks
SEACA_STORE_PASSWORD=change-me-ascii-only
SEACA_KEY_ALIAS=seaca
SEACA_KEY_PASSWORD=change-me-ascii-only
+16
View File
@@ -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")