Compare commits

..

15 Commits

Author SHA1 Message Date
PTah 6398d48e06 chore: release 0.5.16 (include_hidden host events)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:25:09 +10:00
PTah 3bb81e40af fix: load hidden host events via include_hidden API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:16:10 +10:00
PTah d794d7dc48 fix: hide session terminate after SAC marks event terminated
Read session_terminated from API and update UI after successful logoff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:45:36 +10:00
PTah 47842e0ef3 docs: sync README versions and features for 0.5.15
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:14:44 +10:00
PTah c78e11f265 feat: release signing via secrets.properties and shared keystore
Gradle reads signing credentials from repo-root secrets.properties;
secrets.properties.example documents setup for work and home builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:55:25 +10:00
PTah 0f05c3b2c3 fix: clear SAC push notifications when app is opened (0.5.15)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:09:39 +10:00
PTah c0842eb84b feat: RDG session break aligned with SAC rdg_qwinsta_enabled (0.5.14)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:08:41 +10:00
PTah 477e5ea6ed feat: host sessions and event terminate actions (0.5.13)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:22:21 +10:00
PTah 6592f027a1 fix(push): suppress notifications when app is in foreground (0.5.12)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 09:11:19 +10:00
PTah c6433d6fd3 chore: keep mirror README templates on kalinamall for push-remotes
Шаблоны github/home/kalinamall остаются в scripts/readme/ на эталонном git.
2026-06-21 20:16:30 +10:00
PTah 0884cc3d29 chore: sync kalinamall (README и файлы зеркала) 2026-06-21 20:13:47 +10:00
PTah 702b61726e fix: restore mirror README templates for home and github
Обновлён kalinamall.md; восстановлены github.md и home.md для push-remotes.sh.
2026-06-21 20:13:40 +10:00
PTah 16dd8ca3cc merge: sync main with kalinamall (0.5.11) 2026-06-21 19:43:30 +10:00
Andrey Lutsenko 2eda925101 fix: .cursor в gitignore, sync без git add -A и без .cursor на remotes 2026-06-14 21:42:45 +10:00
Andrey Lutsenko f912a4889a fix: sync remotes без чужих readme-шаблонов, check-github-clean
Каждое зеркало получает только свой README и шаблон; GitHub без internal URL в дереве.
2026-06-14 21:42:22 +10:00
19 changed files with 780 additions and 102 deletions
+18 -7
View File
@@ -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
View File
@@ -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,15 +115,29 @@ 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 { }
RdgQwinstaButton( rdgQwinstaEventId(d)?.let {
controller = qwinsta, RdgQwinstaButton(
onClick = { qwinsta.openFor(d, scope) }, controller = qwinsta,
modifier = Modifier.padding(vertical = 4.dp), 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("Severity", d.severity)
@@ -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
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://github.com/PTah/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://github.com/PTah/seaca |
| security-alert-center | https://github.com/PTah/security-alert-center |
| ssh-monitor | https://github.com/PTah/ssh-monitor |
| RDP-login-monitor | https://github.com/PTah/RDP-login-monitor |
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Нет в приложении (только веб-SAC)
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
## Привязка
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
[docs/operator-guide.md](docs/operator-guide.md)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Репозитории
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.papatramp.ru/PapaTramp/seaca |
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/rdp-login-monitor |
+25 -66
View File
@@ -1,87 +1,46 @@
# Seaca # Seaca
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android. Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»). **Версия:** `0.5.15` (versionCode 29)
## Связанные репозитории ## Возможности
| Репозиторий | Роль | - Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|-------------|------| - Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | - Ack / Resolve проблем (monitor+)
| ssh-monitor | Агент Linux | - **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
| RDP-login-monitor | Агент Windows | - Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
| Репозиторий | URL | ## Нет в приложении (только веб-SAC)
|-------------|-----|
| 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 |
## Стек
| Слой | Технология | Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|------|------------|
| 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) |
## Что есть в приложении ## Привязка
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями) 1. Админ: мобильные устройства + код `sacmob_…`
- Pull-to-refresh на обзоре и в списках 2. URL API SAC + код + логин/пароль в Seaca
- Фильтры событий и проблем (severity, status) 3. FCM-токен регистрируется автоматически
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
- Выбор звука уведомления: системный или свой (ringtone picker)
- Биометрическая блокировка при **запуске** и при возврате в приложение
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб) [docs/operator-guide.md](docs/operator-guide.md)
- Управление пользователями 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=…`). JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash ```bash
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
./gradlew assembleDebug ./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
``` ```
APK: `app/build/outputs/apk/debug/app-debug.apk` APK: `app/build/outputs/apk/debug/app-debug.apk`
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает). ## Репозитории
## Лицензия | Репозиторий | URL |
|-------------|-----|
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта). | seaca | https://git.kalinamall.ru/PapaTramp/seaca |
| 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 |
+8
View File
@@ -0,0 +1,8 @@
# Скопируйте в secrets.properties в корне репозитория (рядом с gradlew.bat).
# Пароль — только ASCII (латиница, цифры, символы). Store и key — один пароль.
# seaca-release.jks — тот же файл на рабочем и домашнем ПК (не в git).
SEACA_STORE_FILE=seaca-release.jks
SEACA_STORE_PASSWORD=change-me-ascii-only
SEACA_KEY_ALIAS=seaca
SEACA_KEY_PASSWORD=change-me-ascii-only