Compare commits
11 Commits
702b61726e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6398d48e06 | |||
| 3bb81e40af | |||
| d794d7dc48 | |||
| 47842e0ef3 | |||
| c78e11f265 | |||
| 0f05c3b2c3 | |||
| c0842eb84b | |||
| 477e5ea6ed | |||
| 6592f027a1 | |||
| c6433d6fd3 | |||
| 0884cc3d29 |
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
**Версия:** `0.5.11` (versionCode 25)
|
**Версия:** `0.5.16` (versionCode 30)
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
- Push (FCM), режимы звука, выбор ringtone
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
- Ack / Resolve проблем (monitor+)
|
- Ack / Resolve проблем (monitor+)
|
||||||
- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
- Биометрия при запуске и возврате в приложение
|
- Биометрия при запуске и возврате в приложение
|
||||||
- Формат даты, экран «Моё устройство»
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
@@ -29,15 +30,25 @@ Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/Pap
|
|||||||
|
|
||||||
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
|
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
|
||||||
|
|
||||||
|
**Debug** (ключ свой на каждом ПК):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
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 |
|
||||||
|-------------|------|
|
|-------------|-----|
|
||||||
| security-alert-center | Сервер API |
|
| seaca | https://git.kalinamall.ru/PapaTramp/seaca |
|
||||||
| ssh-monitor / RDP-login-monitor | Агенты |
|
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
|
||||||
|
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
|
||||||
|
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/rdp-login-monitor |
|
||||||
|
|||||||
+28
-2
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
@@ -5,6 +7,15 @@ plugins {
|
|||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
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 {
|
android {
|
||||||
namespace = "ru.kalinamall.seaca"
|
namespace = "ru.kalinamall.seaca"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -13,12 +24,26 @@ android {
|
|||||||
applicationId = "ru.kalinamall.seaca"
|
applicationId = "ru.kalinamall.seaca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 25
|
versionCode = 30
|
||||||
versionName = "0.5.11"
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
if (releaseKeystoreConfigured) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
@@ -54,6 +79,7 @@ dependencies {
|
|||||||
implementation("androidx.activity:activity-compose:1.9.3")
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.3")
|
implementation("androidx.navigation:navigation-compose:2.8.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
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.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package ru.kalinamall.seaca
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import ru.kalinamall.seaca.data.SessionStore
|
import ru.kalinamall.seaca.data.SessionStore
|
||||||
|
import ru.kalinamall.seaca.push.AppForegroundState
|
||||||
import ru.kalinamall.seaca.push.NotificationHelper
|
import ru.kalinamall.seaca.push.NotificationHelper
|
||||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||||
|
|
||||||
class SeacaApplication : Application() {
|
class SeacaApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
AppForegroundState.bind(this)
|
||||||
val store = SessionStore(this)
|
val store = SessionStore(this)
|
||||||
val settings = runBlocking {
|
val settings = runBlocking {
|
||||||
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
|
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface SacApi {
|
|||||||
@Query("page_size") pageSize: Int = 30,
|
@Query("page_size") pageSize: Int = 30,
|
||||||
@Query("severity") severity: String? = null,
|
@Query("severity") severity: String? = null,
|
||||||
@Query("type") type: 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("hostname") hostname: String? = null,
|
||||||
@Query("q") q: String? = null,
|
@Query("q") q: String? = null,
|
||||||
): EventListResponse
|
): EventListResponse
|
||||||
@@ -45,6 +47,21 @@ interface SacApi {
|
|||||||
@Body body: LogoffActionRequest,
|
@Body body: LogoffActionRequest,
|
||||||
): AgentCommandResponse
|
): 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")
|
@GET("api/v1/problems")
|
||||||
suspend fun listProblems(
|
suspend fun listProblems(
|
||||||
@Query("page") page: Int = 1,
|
@Query("page") page: Int = 1,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ data class EventSummary(
|
|||||||
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
|
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
|
||||||
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
|
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
|
||||||
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: 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
|
@Serializable
|
||||||
@@ -87,6 +90,9 @@ data class EventDetail(
|
|||||||
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
|
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
|
||||||
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
|
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
|
||||||
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: 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 details: JsonElement? = null,
|
||||||
val raw: JsonElement? = null,
|
val raw: JsonElement? = null,
|
||||||
val payload: JsonElement? = null,
|
val payload: JsonElement? = null,
|
||||||
@@ -204,6 +210,43 @@ data class LogoffActionRequest(
|
|||||||
@SerialName("session_id") val sessionId: Int,
|
@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
|
@Serializable
|
||||||
data class AgentCommandResponse(
|
data class AgentCommandResponse(
|
||||||
@SerialName("command_uuid") val commandUuid: String,
|
@SerialName("command_uuid") val commandUuid: String,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -83,6 +83,10 @@ object NotificationHelper {
|
|||||||
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
|
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) {
|
||||||
|
context.getSystemService(NotificationManager::class.java).cancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
fun show(
|
fun show(
|
||||||
context: Context,
|
context: Context,
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
|||||||
private val repo by lazy { SacRepository(applicationContext) }
|
private val repo by lazy { SacRepository(applicationContext) }
|
||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
if (AppForegroundState.isInForeground()) return
|
||||||
val mode = runBlocking { repo.session.getNotificationMode() }
|
val mode = runBlocking { repo.session.getNotificationMode() }
|
||||||
when (mode) {
|
when (mode) {
|
||||||
NotificationMode.OFF -> return
|
NotificationMode.OFF -> return
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ import ru.kalinamall.seaca.data.SacRepository
|
|||||||
import ru.kalinamall.seaca.ui.util.QwinstaSessionRow
|
import ru.kalinamall.seaca.ui.util.QwinstaSessionRow
|
||||||
import ru.kalinamall.seaca.ui.util.agentCommandError
|
import ru.kalinamall.seaca.ui.util.agentCommandError
|
||||||
import ru.kalinamall.seaca.ui.util.parseQwinstaSessions
|
import ru.kalinamall.seaca.ui.util.parseQwinstaSessions
|
||||||
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
|
||||||
|
|
||||||
class RdgQwinstaController(private val repo: SacRepository) {
|
class RdgQwinstaController(private val repo: SacRepository) {
|
||||||
var open by mutableStateOf(false)
|
var open by mutableStateOf(false)
|
||||||
@@ -61,7 +61,7 @@ class RdgQwinstaController(private val repo: SacRepository) {
|
|||||||
private var actorUser: String? = null
|
private var actorUser: String? = null
|
||||||
|
|
||||||
fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
|
fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
|
||||||
val targetId = rdgFlapQwinstaEventId(event) ?: return
|
val targetId = rdgQwinstaEventId(event) ?: return
|
||||||
targetEventId = targetId
|
targetEventId = targetId
|
||||||
actorUser = event.actorUser
|
actorUser = event.actorUser
|
||||||
open = true
|
open = true
|
||||||
@@ -69,7 +69,7 @@ class RdgQwinstaController(private val repo: SacRepository) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
|
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
|
||||||
val targetId = rdgFlapQwinstaEventId(event) ?: return
|
val targetId = rdgQwinstaEventId(event) ?: return
|
||||||
targetEventId = targetId
|
targetEventId = targetId
|
||||||
actorUser = event.actorUser
|
actorUser = event.actorUser
|
||||||
open = true
|
open = true
|
||||||
@@ -191,7 +191,7 @@ fun RdgQwinstaDialog(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text("qwinsta / logoff", style = MaterialTheme.typography.titleLarge)
|
Text("Оборвать сессию", style = MaterialTheme.typography.titleLarge)
|
||||||
controller.meta?.let {
|
controller.meta?.let {
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -329,6 +329,6 @@ fun RdgQwinstaButton(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(if (controller.loading) "qwinsta…" else "qwinsta")
|
Text(if (controller.loading) "…" else "Оборвать сессию")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -48,6 +49,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import retrofit2.HttpException
|
||||||
import ru.kalinamall.seaca.R
|
import ru.kalinamall.seaca.R
|
||||||
import ru.kalinamall.seaca.data.EventSummary
|
import ru.kalinamall.seaca.data.EventSummary
|
||||||
import ru.kalinamall.seaca.data.EventDetail
|
import ru.kalinamall.seaca.data.EventDetail
|
||||||
@@ -63,8 +65,12 @@ import ru.kalinamall.seaca.data.SacRepository
|
|||||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
||||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
||||||
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
|
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.hasRdgFlapUi
|
||||||
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
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.NotificationHelper
|
||||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||||
import ru.kalinamall.seaca.ui.RingtonePicker
|
import ru.kalinamall.seaca.ui.RingtonePicker
|
||||||
@@ -89,6 +95,9 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val qwinsta = rememberRdgQwinstaController(repo)
|
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(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
initialValue = DateTimeDisplayMode.SLASH,
|
||||||
)
|
)
|
||||||
@@ -106,16 +115,30 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
|
|
||||||
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
||||||
val d = data ?: return@DetailFrame
|
val d = data ?: return@DetailFrame
|
||||||
|
if (!d.rdgAccessPath.isNullOrBlank()) {
|
||||||
|
Field("Путь RDS", d.rdgAccessPath)
|
||||||
|
}
|
||||||
if (hasRdgFlapUi(d)) {
|
if (hasRdgFlapUi(d)) {
|
||||||
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
|
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
|
||||||
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
|
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
|
||||||
rdgFlapQwinstaEventId(d)?.let {
|
}
|
||||||
|
rdgQwinstaEventId(d)?.let {
|
||||||
RdgQwinstaButton(
|
RdgQwinstaButton(
|
||||||
controller = qwinsta,
|
controller = qwinsta,
|
||||||
onClick = { qwinsta.openFor(d, scope) },
|
onClick = { qwinsta.openFor(d, scope) },
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
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("Severity", d.severity)
|
||||||
Field(
|
Field(
|
||||||
@@ -133,6 +156,53 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
RdgQwinstaDialog(qwinsta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,10 +386,10 @@ fun HostDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (HostDetailTab.entries[selectedTab]) {
|
when (HostDetailTab.entries[selectedTab]) {
|
||||||
HostDetailTab.Info -> HostInfoContent(host, dateMode)
|
HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
|
||||||
HostDetailTab.Events -> HostEventsContent(
|
HostDetailTab.Events -> HostEventsContent(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
hostname = host.hostname,
|
hostId = host.id,
|
||||||
onOpenEvent = onOpenEvent,
|
onOpenEvent = onOpenEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -328,7 +398,7 @@ fun HostDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -341,6 +411,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
|||||||
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
|
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
|
||||||
Field("Агент", host.agentStatus)
|
Field("Агент", host.agentStatus)
|
||||||
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
|
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
|
||||||
|
HostSessionsSection(repo = repo, hostId = host.id)
|
||||||
host.inventory?.let { inv ->
|
host.inventory?.let { inv ->
|
||||||
val lines = inventoryFieldLines(inv)
|
val lines = inventoryFieldLines(inv)
|
||||||
if (lines.isEmpty()) {
|
if (lines.isEmpty()) {
|
||||||
@@ -357,7 +428,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun HostEventsContent(
|
private fun HostEventsContent(
|
||||||
repo: SacRepository,
|
repo: SacRepository,
|
||||||
hostname: String,
|
hostId: Long,
|
||||||
onOpenEvent: (Long) -> Unit,
|
onOpenEvent: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
var page by remember { mutableIntStateOf(1) }
|
var page by remember { mutableIntStateOf(1) }
|
||||||
@@ -377,7 +448,12 @@ private fun HostEventsContent(
|
|||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
val res = repo.withAuth {
|
val res = repo.withAuth {
|
||||||
it.listEvents(page = page, pageSize = 30, hostname = hostname)
|
it.listEvents(
|
||||||
|
page = page,
|
||||||
|
pageSize = 30,
|
||||||
|
hostId = hostId,
|
||||||
|
includeHidden = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
items = res.items
|
items = res.items
|
||||||
total = res.total
|
total = res.total
|
||||||
@@ -390,7 +466,7 @@ private fun HostEventsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(page, hostname) { load() }
|
LaunchedEffect(page, hostId) { load() }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MenuAnchorType
|
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.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -39,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
import ru.kalinamall.seaca.data.DateTimeDisplayMode
|
import ru.kalinamall.seaca.data.DateTimeDisplayMode
|
||||||
import ru.kalinamall.seaca.data.DashboardSummary
|
import ru.kalinamall.seaca.data.DashboardSummary
|
||||||
import ru.kalinamall.seaca.data.EventListResponse
|
import ru.kalinamall.seaca.data.EventListResponse
|
||||||
@@ -47,6 +52,8 @@ import ru.kalinamall.seaca.data.HostSummary
|
|||||||
import ru.kalinamall.seaca.data.ProblemSummary
|
import ru.kalinamall.seaca.data.ProblemSummary
|
||||||
import ru.kalinamall.seaca.data.SacRepository
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
import ru.kalinamall.seaca.ui.components.BackArrowButton
|
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.RdgFlapBadge
|
||||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
||||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
||||||
@@ -54,7 +61,9 @@ import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
|
|||||||
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
|
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
|
||||||
import ru.kalinamall.seaca.ui.util.SacDateTimes
|
import ru.kalinamall.seaca.ui.util.SacDateTimes
|
||||||
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
||||||
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
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(
|
private val REPORT_TYPE_OPTIONS = listOf(
|
||||||
"report.daily.ssh" to "Отчёты ssh клиентов",
|
"report.daily.ssh" to "Отчёты ssh клиентов",
|
||||||
@@ -130,6 +139,9 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (!e.rdgAccessPath.isNullOrBlank()) {
|
||||||
|
RdgAccessBadge(path = e.rdgAccessPath)
|
||||||
|
}
|
||||||
if (hasRdgFlapUi(e)) {
|
if (hasRdgFlapUi(e)) {
|
||||||
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
|
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
|
||||||
}
|
}
|
||||||
@@ -138,7 +150,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (rdgFlapQwinstaEventId(e) != null) {
|
rdgQwinstaEventId(e)?.let {
|
||||||
RdgQwinstaButton(
|
RdgQwinstaButton(
|
||||||
controller = qwinsta,
|
controller = qwinsta,
|
||||||
onClick = { qwinsta.openFor(e, scope) },
|
onClick = { qwinsta.openFor(e, scope) },
|
||||||
@@ -192,10 +204,53 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
|||||||
var hostname by remember { mutableStateOf("") }
|
var hostname by remember { mutableStateOf("") }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val qwinsta = rememberRdgQwinstaController(repo)
|
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(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
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(
|
PagedListScreen(
|
||||||
title = "События",
|
title = "События",
|
||||||
loadPage = { page, severity, host ->
|
loadPage = { page, severity, host ->
|
||||||
@@ -212,15 +267,24 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
|||||||
itemKey = EventSummary::id,
|
itemKey = EventSummary::id,
|
||||||
itemLabel = { "${it.severity} · ${it.title}" },
|
itemLabel = { "${it.severity} · ${it.title}" },
|
||||||
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
||||||
|
itemRdgAccessPath = { it.rdgAccessPath },
|
||||||
itemRdgFlap = { hasRdgFlapUi(it) },
|
itemRdgFlap = { hasRdgFlapUi(it) },
|
||||||
itemRdgFlapPairId = { it.rdgFlapPairEventId },
|
itemRdgFlapPairId = { it.rdgFlapPairEventId },
|
||||||
itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
|
itemQwinsta = { rdgQwinstaEventId(it) != null },
|
||||||
onQwinsta = { qwinsta.openFor(it, scope) },
|
onQwinsta = { qwinsta.openFor(it, scope) },
|
||||||
qwinstaLoading = qwinsta.loading,
|
qwinstaLoading = qwinsta.loading,
|
||||||
|
itemTerminate = {
|
||||||
|
eventSupportsSessionTerminate(it) && it.id !in locallyTerminatedIds
|
||||||
|
},
|
||||||
|
onTerminate = { terminateConfirm = it },
|
||||||
|
terminateLoadingId = terminateLoadingId,
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
severities = listOf(null, "info", "warning", "high", "critical"),
|
severities = listOf(null, "info", "warning", "high", "critical"),
|
||||||
hostnameFilter = hostname,
|
hostnameFilter = hostname,
|
||||||
headerContent = {
|
headerContent = {
|
||||||
|
terminateError?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = hostname,
|
value = hostname,
|
||||||
onValueChange = { hostname = it },
|
onValueChange = { hostname = it },
|
||||||
@@ -354,11 +418,15 @@ private fun <T> PagedListScreen(
|
|||||||
itemSub: (T) -> String,
|
itemSub: (T) -> String,
|
||||||
onOpen: (Long) -> Unit,
|
onOpen: (Long) -> Unit,
|
||||||
severities: List<String?>,
|
severities: List<String?>,
|
||||||
|
itemRdgAccessPath: ((T) -> String?)? = null,
|
||||||
itemRdgFlap: ((T) -> Boolean)? = null,
|
itemRdgFlap: ((T) -> Boolean)? = null,
|
||||||
itemRdgFlapPairId: ((T) -> Long?)? = null,
|
itemRdgFlapPairId: ((T) -> Long?)? = null,
|
||||||
itemQwinsta: ((T) -> Boolean)? = null,
|
itemQwinsta: ((T) -> Boolean)? = null,
|
||||||
onQwinsta: ((T) -> Unit)? = null,
|
onQwinsta: ((T) -> Unit)? = null,
|
||||||
qwinstaLoading: Boolean = false,
|
qwinstaLoading: Boolean = false,
|
||||||
|
itemTerminate: ((T) -> Boolean)? = null,
|
||||||
|
onTerminate: ((T) -> Unit)? = null,
|
||||||
|
terminateLoadingId: Long? = null,
|
||||||
filterLabel: String = "Severity",
|
filterLabel: String = "Severity",
|
||||||
hostnameFilter: String = "",
|
hostnameFilter: String = "",
|
||||||
headerContent: @Composable () -> Unit = {},
|
headerContent: @Composable () -> Unit = {},
|
||||||
@@ -421,11 +489,15 @@ private fun <T> PagedListScreen(
|
|||||||
title = itemLabel(item),
|
title = itemLabel(item),
|
||||||
subtitle = itemSub(item),
|
subtitle = itemSub(item),
|
||||||
onClick = { onOpen(itemKey(item)) },
|
onClick = { onOpen(itemKey(item)) },
|
||||||
|
rdgAccessPath = itemRdgAccessPath?.invoke(item),
|
||||||
rdgFlap = itemRdgFlap?.invoke(item) == true,
|
rdgFlap = itemRdgFlap?.invoke(item) == true,
|
||||||
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
|
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
|
||||||
showQwinsta = itemQwinsta?.invoke(item) == true,
|
showQwinsta = itemQwinsta?.invoke(item) == true,
|
||||||
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
|
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
|
||||||
qwinstaLoading = qwinstaLoading,
|
qwinstaLoading = qwinstaLoading,
|
||||||
|
showTerminate = itemTerminate?.invoke(item) == true,
|
||||||
|
onTerminateClick = onTerminate?.let { handler -> { handler(item) } },
|
||||||
|
terminateLoading = terminateLoadingId == itemKey(item),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,11 +511,15 @@ private fun ListRow(
|
|||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
rdgAccessPath: String? = null,
|
||||||
rdgFlap: Boolean = false,
|
rdgFlap: Boolean = false,
|
||||||
rdgFlapPairId: Long? = null,
|
rdgFlapPairId: Long? = null,
|
||||||
showQwinsta: Boolean = false,
|
showQwinsta: Boolean = false,
|
||||||
onQwinstaClick: (() -> Unit)? = null,
|
onQwinstaClick: (() -> Unit)? = null,
|
||||||
qwinstaLoading: Boolean = false,
|
qwinstaLoading: Boolean = false,
|
||||||
|
showTerminate: Boolean = false,
|
||||||
|
onTerminateClick: (() -> Unit)? = null,
|
||||||
|
terminateLoading: Boolean = false,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -461,6 +537,9 @@ private fun ListRow(
|
|||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
if (!rdgAccessPath.isNullOrBlank()) {
|
||||||
|
RdgAccessBadge(path = rdgAccessPath)
|
||||||
|
}
|
||||||
if (rdgFlap) {
|
if (rdgFlap) {
|
||||||
RdgFlapBadge(pairEventId = rdgFlapPairId)
|
RdgFlapBadge(pairEventId = rdgFlapPairId)
|
||||||
}
|
}
|
||||||
@@ -471,7 +550,14 @@ private fun ListRow(
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
enabled = !qwinstaLoading,
|
enabled = !qwinstaLoading,
|
||||||
onClick = onQwinstaClick,
|
onClick = onQwinstaClick,
|
||||||
) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") }
|
) { Text(if (qwinstaLoading) "…" else "Оборвать сессию") }
|
||||||
|
}
|
||||||
|
if (showTerminate && onTerminateClick != null) {
|
||||||
|
EventSessionTerminateButton(
|
||||||
|
enabled = !terminateLoading,
|
||||||
|
loading = terminateLoading,
|
||||||
|
onClick = onTerminateClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,31 @@ data class QwinstaSessionRow(
|
|||||||
fun hasRdgFlapUi(event: EventSummary): Boolean =
|
fun hasRdgFlapUi(event: EventSummary): Boolean =
|
||||||
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
||||||
|
|
||||||
fun rdgFlapQwinstaEventId(event: EventSummary): Long? =
|
|
||||||
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
|
|
||||||
|
|
||||||
fun hasRdgFlapUi(event: EventDetail): Boolean =
|
fun hasRdgFlapUi(event: EventDetail): Boolean =
|
||||||
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
||||||
|
|
||||||
fun rdgFlapQwinstaEventId(event: EventDetail): Long? =
|
fun hasRdgAccessPath(event: EventSummary): Boolean =
|
||||||
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
|
!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> {
|
fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessionRow> {
|
||||||
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
Android-клиент [Security Alert Center (SAC)](https://github.com/PTah/security-alert-center).
|
Android-клиент [Security Alert Center (SAC)](https://github.com/PTah/security-alert-center).
|
||||||
|
|
||||||
**Версия:** `0.5.11` (versionCode 25)
|
**Версия:** `0.5.15` (versionCode 29)
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
- Push (FCM), режимы звука, выбор ringtone
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
- Ack / Resolve проблем (monitor+)
|
- Ack / Resolve проблем (monitor+)
|
||||||
- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
- Биометрия при запуске и возврате в приложение
|
- Биометрия при запуске и возврате в приложение
|
||||||
- Формат даты, экран «Моё устройство»
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
|
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
**Версия:** `0.5.11` (versionCode 25)
|
**Версия:** `0.5.15` (versionCode 29)
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
- Push (FCM), режимы звука, выбор ringtone
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
- Ack / Resolve проблем (monitor+)
|
- Ack / Resolve проблем (monitor+)
|
||||||
- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
- Биометрия при запуске и возврате в приложение
|
- Биометрия при запуске и возврате в приложение
|
||||||
- Формат даты, экран «Моё устройство»
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/Papa
|
|||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
|
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
**Версия:** `0.5.11` (versionCode 25)
|
**Версия:** `0.5.15` (versionCode 29)
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
- Push (FCM), режимы звука, выбор ringtone
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
- Ack / Resolve проблем (monitor+)
|
- Ack / Resolve проблем (monitor+)
|
||||||
- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
- Биометрия при запуске и возврате в приложение
|
- Биометрия при запуске и возврате в приложение
|
||||||
- Формат даты, экран «Моё устройство»
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
@@ -23,11 +24,11 @@ Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/Pap
|
|||||||
2. URL API SAC + код + логин/пароль в Seaca
|
2. URL API SAC + код + логин/пароль в Seaca
|
||||||
3. FCM-токен регистрируется автоматически
|
3. FCM-токен регистрируется автоматически
|
||||||
|
|
||||||
[docs/operator-guide.md](docs/operator-guide.md) · SAC [docs/seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md)
|
[docs/operator-guide.md](docs/operator-guide.md)
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
|
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user