chore(home): mirror from kalinamall (6398d48) with papatramp URLs
This commit is contained in:
+33
@@ -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
|
||||||
@@ -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 |
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
# Seaca — defaults sufficient for debug/release without minify
|
||||||
@@ -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(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#15202B</color>
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Seaca" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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>
|
||||||
@@ -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
@@ -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 < 45 с (как Telegram)
|
||||||
|
- Pairing по коду: < 2 минут для оператора
|
||||||
|
- Отзыв устройства: push прекращается в течение 1 минуты
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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] готово."
|
||||||
@@ -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 (уточняется владельцем проекта).
|
||||||
@@ -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 |
|
||||||
@@ -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 |
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user