Compare commits
15 Commits
6a0758ffad
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6398d48e06 | |||
| 3bb81e40af | |||
| d794d7dc48 | |||
| 47842e0ef3 | |||
| c78e11f265 | |||
| 0f05c3b2c3 | |||
| c0842eb84b | |||
| 477e5ea6ed | |||
| 6592f027a1 | |||
| c6433d6fd3 | |||
| 0884cc3d29 | |||
| 702b61726e | |||
| 16dd8ca3cc | |||
| 2eda925101 | |||
| f912a4889a |
@@ -2,14 +2,15 @@
|
||||
|
||||
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)
|
||||
- Push (FCM), режимы звука, выбор ringtone
|
||||
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||
- 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.
|
||||
|
||||
**Debug** (ключ свой на каждом ПК):
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
**Release** (один ключ на всех ПК — для обновлений поверх старой версии):
|
||||
|
||||
1. Положить `seaca-release.jks` в корень репозитория (не в git).
|
||||
2. Скопировать `secrets.properties.example` → `secrets.properties`, подставить пароль (ASCII).
|
||||
3. `.\gradlew assembleRelease` — APK: `app/build/outputs/apk/release/app-release.apk`
|
||||
|
||||
## Репозитории
|
||||
|
||||
| Репозиторий | Роль |
|
||||
|-------------|------|
|
||||
| security-alert-center | Сервер API |
|
||||
| ssh-monitor / RDP-login-monitor | Агенты |
|
||||
| Репозиторий | URL |
|
||||
|-------------|-----|
|
||||
| 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 |
|
||||
|
||||
+28
-2
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
@@ -5,6 +7,15 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
val secretsFile = rootProject.file("secrets.properties")
|
||||
val releaseSecrets = Properties().apply {
|
||||
if (secretsFile.exists()) {
|
||||
secretsFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
val releaseKeystoreConfigured = secretsFile.exists() &&
|
||||
releaseSecrets.getProperty("SEACA_STORE_FILE") != null
|
||||
|
||||
android {
|
||||
namespace = "ru.kalinamall.seaca"
|
||||
compileSdk = 35
|
||||
@@ -13,12 +24,26 @@ android {
|
||||
applicationId = "ru.kalinamall.seaca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 25
|
||||
versionName = "0.5.11"
|
||||
versionCode = 30
|
||||
versionName = "0.5.16"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (releaseKeystoreConfigured) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(releaseSecrets.getProperty("SEACA_STORE_FILE"))
|
||||
storePassword = releaseSecrets.getProperty("SEACA_STORE_PASSWORD")
|
||||
keyAlias = releaseSecrets.getProperty("SEACA_KEY_ALIAS")
|
||||
keyPassword = releaseSecrets.getProperty("SEACA_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (releaseKeystoreConfigured) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
@@ -54,6 +79,7 @@ dependencies {
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
@@ -3,12 +3,14 @@ package ru.kalinamall.seaca
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.kalinamall.seaca.data.SessionStore
|
||||
import ru.kalinamall.seaca.push.AppForegroundState
|
||||
import ru.kalinamall.seaca.push.NotificationHelper
|
||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||
|
||||
class SeacaApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AppForegroundState.bind(this)
|
||||
val store = SessionStore(this)
|
||||
val settings = runBlocking {
|
||||
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
|
||||
|
||||
@@ -29,6 +29,8 @@ interface SacApi {
|
||||
@Query("page_size") pageSize: Int = 30,
|
||||
@Query("severity") severity: String? = null,
|
||||
@Query("type") type: String? = null,
|
||||
@Query("host_id") hostId: Long? = null,
|
||||
@Query("include_hidden") includeHidden: Boolean? = null,
|
||||
@Query("hostname") hostname: String? = null,
|
||||
@Query("q") q: String? = null,
|
||||
): EventListResponse
|
||||
@@ -45,6 +47,21 @@ interface SacApi {
|
||||
@Body body: LogoffActionRequest,
|
||||
): AgentCommandResponse
|
||||
|
||||
@POST("api/v1/events/{id}/actions/terminate-session")
|
||||
suspend fun postEventTerminateSession(
|
||||
@Path("id") id: Long,
|
||||
@Body body: EventSessionTerminateRequest = EventSessionTerminateRequest(),
|
||||
): SessionActionResponse
|
||||
|
||||
@POST("api/v1/hosts/{id}/actions/sessions/list")
|
||||
suspend fun postHostSessionsList(@Path("id") id: Long): HostSessionsResponse
|
||||
|
||||
@POST("api/v1/hosts/{id}/actions/sessions/terminate")
|
||||
suspend fun postHostSessionTerminate(
|
||||
@Path("id") id: Long,
|
||||
@Body body: HostSessionTerminateRequest,
|
||||
): SessionActionResponse
|
||||
|
||||
@GET("api/v1/problems")
|
||||
suspend fun listProblems(
|
||||
@Query("page") page: Int = 1,
|
||||
|
||||
@@ -61,6 +61,9 @@ data class EventSummary(
|
||||
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
|
||||
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
|
||||
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
|
||||
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
|
||||
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
|
||||
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -87,6 +90,9 @@ data class EventDetail(
|
||||
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
|
||||
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
|
||||
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
|
||||
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
|
||||
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
|
||||
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
|
||||
val details: JsonElement? = null,
|
||||
val raw: JsonElement? = null,
|
||||
val payload: JsonElement? = null,
|
||||
@@ -204,6 +210,43 @@ data class LogoffActionRequest(
|
||||
@SerialName("session_id") val sessionId: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HostSessionItem(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
val user: String,
|
||||
val tty: String? = null,
|
||||
val state: String? = null,
|
||||
@SerialName("source_ip") val sourceIp: String? = null,
|
||||
@SerialName("session_name") val sessionName: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HostSessionsResponse(
|
||||
val ok: Boolean,
|
||||
val message: String,
|
||||
val target: String? = null,
|
||||
val sessions: List<HostSessionItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HostSessionTerminateRequest(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EventSessionTerminateRequest(
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionActionResponse(
|
||||
val ok: Boolean,
|
||||
val message: String,
|
||||
val target: String? = null,
|
||||
val stdout: String? = null,
|
||||
val stderr: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AgentCommandResponse(
|
||||
@SerialName("command_uuid") val commandUuid: String,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
context.getSystemService(NotificationManager::class.java).cancelAll()
|
||||
}
|
||||
|
||||
fun show(
|
||||
context: Context,
|
||||
title: String,
|
||||
|
||||
@@ -15,6 +15,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
||||
private val repo by lazy { SacRepository(applicationContext) }
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
if (AppForegroundState.isInForeground()) return
|
||||
val mode = runBlocking { repo.session.getNotificationMode() }
|
||||
when (mode) {
|
||||
NotificationMode.OFF -> return
|
||||
|
||||
@@ -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.agentCommandError
|
||||
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) {
|
||||
var open by mutableStateOf(false)
|
||||
@@ -61,7 +61,7 @@ class RdgQwinstaController(private val repo: SacRepository) {
|
||||
private var actorUser: String? = null
|
||||
|
||||
fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
|
||||
val targetId = rdgFlapQwinstaEventId(event) ?: return
|
||||
val targetId = rdgQwinstaEventId(event) ?: return
|
||||
targetEventId = targetId
|
||||
actorUser = event.actorUser
|
||||
open = true
|
||||
@@ -69,7 +69,7 @@ class RdgQwinstaController(private val repo: SacRepository) {
|
||||
}
|
||||
|
||||
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
|
||||
val targetId = rdgFlapQwinstaEventId(event) ?: return
|
||||
val targetId = rdgQwinstaEventId(event) ?: return
|
||||
targetEventId = targetId
|
||||
actorUser = event.actorUser
|
||||
open = true
|
||||
@@ -191,7 +191,7 @@ fun RdgQwinstaDialog(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("qwinsta / logoff", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Оборвать сессию", style = MaterialTheme.typography.titleLarge)
|
||||
controller.meta?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -329,6 +329,6 @@ fun RdgQwinstaButton(
|
||||
onClick = onClick,
|
||||
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.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Card
|
||||
@@ -48,6 +49,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import retrofit2.HttpException
|
||||
import ru.kalinamall.seaca.R
|
||||
import ru.kalinamall.seaca.data.EventSummary
|
||||
import ru.kalinamall.seaca.data.EventDetail
|
||||
@@ -63,8 +65,12 @@ import ru.kalinamall.seaca.data.SacRepository
|
||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
||||
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
|
||||
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
|
||||
import ru.kalinamall.seaca.ui.components.HostSessionsSection
|
||||
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
||||
import ru.kalinamall.seaca.ui.util.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.NotificationSoundResolver
|
||||
import ru.kalinamall.seaca.ui.RingtonePicker
|
||||
@@ -89,6 +95,9 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val qwinsta = rememberRdgQwinstaController(repo)
|
||||
var terminateLoading by remember { mutableStateOf(false) }
|
||||
var terminateConfirm by remember { mutableStateOf(false) }
|
||||
var terminateMessage by remember { mutableStateOf<String?>(null) }
|
||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||
initialValue = DateTimeDisplayMode.SLASH,
|
||||
)
|
||||
@@ -106,15 +115,29 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
||||
|
||||
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
||||
val d = data ?: return@DetailFrame
|
||||
if (!d.rdgAccessPath.isNullOrBlank()) {
|
||||
Field("Путь RDS", d.rdgAccessPath)
|
||||
}
|
||||
if (hasRdgFlapUi(d)) {
|
||||
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
|
||||
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
|
||||
rdgFlapQwinstaEventId(d)?.let {
|
||||
RdgQwinstaButton(
|
||||
controller = qwinsta,
|
||||
onClick = { qwinsta.openFor(d, scope) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
rdgQwinstaEventId(d)?.let {
|
||||
RdgQwinstaButton(
|
||||
controller = qwinsta,
|
||||
onClick = { qwinsta.openFor(d, scope) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
if (eventSupportsSessionTerminate(d)) {
|
||||
EventSessionTerminateButton(
|
||||
enabled = !terminateLoading,
|
||||
loading = terminateLoading,
|
||||
onClick = { terminateConfirm = true },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
terminateMessage?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Field("Severity", d.severity)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -316,10 +386,10 @@ fun HostDetailScreen(
|
||||
}
|
||||
}
|
||||
when (HostDetailTab.entries[selectedTab]) {
|
||||
HostDetailTab.Info -> HostInfoContent(host, dateMode)
|
||||
HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
|
||||
HostDetailTab.Events -> HostEventsContent(
|
||||
repo = repo,
|
||||
hostname = host.hostname,
|
||||
hostId = host.id,
|
||||
onOpenEvent = onOpenEvent,
|
||||
)
|
||||
}
|
||||
@@ -328,7 +398,7 @@ fun HostDetailScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
||||
private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -341,6 +411,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
||||
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
|
||||
Field("Агент", host.agentStatus)
|
||||
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
|
||||
HostSessionsSection(repo = repo, hostId = host.id)
|
||||
host.inventory?.let { inv ->
|
||||
val lines = inventoryFieldLines(inv)
|
||||
if (lines.isEmpty()) {
|
||||
@@ -357,7 +428,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
||||
@Composable
|
||||
private fun HostEventsContent(
|
||||
repo: SacRepository,
|
||||
hostname: String,
|
||||
hostId: Long,
|
||||
onOpenEvent: (Long) -> Unit,
|
||||
) {
|
||||
var page by remember { mutableIntStateOf(1) }
|
||||
@@ -377,7 +448,12 @@ private fun HostEventsContent(
|
||||
error = null
|
||||
try {
|
||||
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
|
||||
total = res.total
|
||||
@@ -390,7 +466,7 @@ private fun HostEventsContent(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(page, hostname) { load() }
|
||||
LaunchedEffect(page, hostId) { load() }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
|
||||
@@ -21,6 +21,10 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -39,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import ru.kalinamall.seaca.data.DateTimeDisplayMode
|
||||
import ru.kalinamall.seaca.data.DashboardSummary
|
||||
import ru.kalinamall.seaca.data.EventListResponse
|
||||
@@ -47,6 +52,8 @@ import ru.kalinamall.seaca.data.HostSummary
|
||||
import ru.kalinamall.seaca.data.ProblemSummary
|
||||
import ru.kalinamall.seaca.data.SacRepository
|
||||
import ru.kalinamall.seaca.ui.components.BackArrowButton
|
||||
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
|
||||
import ru.kalinamall.seaca.ui.components.RdgAccessBadge
|
||||
import ru.kalinamall.seaca.ui.components.RdgFlapBadge
|
||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
||||
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
||||
@@ -54,7 +61,9 @@ import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
|
||||
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
|
||||
import ru.kalinamall.seaca.ui.util.SacDateTimes
|
||||
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
||||
import ru.kalinamall.seaca.ui.util.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(
|
||||
"report.daily.ssh" to "Отчёты ssh клиентов",
|
||||
@@ -130,6 +139,9 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (!e.rdgAccessPath.isNullOrBlank()) {
|
||||
RdgAccessBadge(path = e.rdgAccessPath)
|
||||
}
|
||||
if (hasRdgFlapUi(e)) {
|
||||
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
|
||||
}
|
||||
@@ -138,7 +150,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (rdgFlapQwinstaEventId(e) != null) {
|
||||
rdgQwinstaEventId(e)?.let {
|
||||
RdgQwinstaButton(
|
||||
controller = qwinsta,
|
||||
onClick = { qwinsta.openFor(e, scope) },
|
||||
@@ -192,10 +204,53 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
||||
var hostname by remember { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
val qwinsta = rememberRdgQwinstaController(repo)
|
||||
var terminateLoadingId by remember { mutableStateOf<Long?>(null) }
|
||||
var terminateConfirm by remember { mutableStateOf<EventSummary?>(null) }
|
||||
var terminateError by remember { mutableStateOf<String?>(null) }
|
||||
var locallyTerminatedIds by remember { mutableStateOf(setOf<Long>()) }
|
||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||
initialValue = DateTimeDisplayMode.SLASH,
|
||||
)
|
||||
|
||||
terminateConfirm?.let { event ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { terminateConfirm = null },
|
||||
title = { Text("Завершить сессию?") },
|
||||
text = { Text("Завершить сессию пользователя ${sessionTerminateLabel(event)}?") },
|
||||
confirmButton = {
|
||||
Button(
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
onClick = {
|
||||
val target = event
|
||||
terminateConfirm = null
|
||||
scope.launch {
|
||||
terminateLoadingId = target.id
|
||||
terminateError = null
|
||||
try {
|
||||
val res = repo.withAuth { it.postEventTerminateSession(target.id) }
|
||||
if (!res.ok) {
|
||||
terminateError = res.message
|
||||
} else {
|
||||
locallyTerminatedIds = locallyTerminatedIds + target.id
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
terminateError = if (e is HttpException) SacRepository.parseError(e) else e.message
|
||||
} finally {
|
||||
terminateLoadingId = null
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("Завершить") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { terminateConfirm = null }) { Text("Отмена") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
PagedListScreen(
|
||||
title = "События",
|
||||
loadPage = { page, severity, host ->
|
||||
@@ -212,15 +267,24 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
||||
itemKey = EventSummary::id,
|
||||
itemLabel = { "${it.severity} · ${it.title}" },
|
||||
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
||||
itemRdgAccessPath = { it.rdgAccessPath },
|
||||
itemRdgFlap = { hasRdgFlapUi(it) },
|
||||
itemRdgFlapPairId = { it.rdgFlapPairEventId },
|
||||
itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
|
||||
itemQwinsta = { rdgQwinstaEventId(it) != null },
|
||||
onQwinsta = { qwinsta.openFor(it, scope) },
|
||||
qwinstaLoading = qwinsta.loading,
|
||||
itemTerminate = {
|
||||
eventSupportsSessionTerminate(it) && it.id !in locallyTerminatedIds
|
||||
},
|
||||
onTerminate = { terminateConfirm = it },
|
||||
terminateLoadingId = terminateLoadingId,
|
||||
onOpen = onOpen,
|
||||
severities = listOf(null, "info", "warning", "high", "critical"),
|
||||
hostnameFilter = hostname,
|
||||
headerContent = {
|
||||
terminateError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = hostname,
|
||||
onValueChange = { hostname = it },
|
||||
@@ -354,11 +418,15 @@ private fun <T> PagedListScreen(
|
||||
itemSub: (T) -> String,
|
||||
onOpen: (Long) -> Unit,
|
||||
severities: List<String?>,
|
||||
itemRdgAccessPath: ((T) -> String?)? = null,
|
||||
itemRdgFlap: ((T) -> Boolean)? = null,
|
||||
itemRdgFlapPairId: ((T) -> Long?)? = null,
|
||||
itemQwinsta: ((T) -> Boolean)? = null,
|
||||
onQwinsta: ((T) -> Unit)? = null,
|
||||
qwinstaLoading: Boolean = false,
|
||||
itemTerminate: ((T) -> Boolean)? = null,
|
||||
onTerminate: ((T) -> Unit)? = null,
|
||||
terminateLoadingId: Long? = null,
|
||||
filterLabel: String = "Severity",
|
||||
hostnameFilter: String = "",
|
||||
headerContent: @Composable () -> Unit = {},
|
||||
@@ -421,11 +489,15 @@ private fun <T> PagedListScreen(
|
||||
title = itemLabel(item),
|
||||
subtitle = itemSub(item),
|
||||
onClick = { onOpen(itemKey(item)) },
|
||||
rdgAccessPath = itemRdgAccessPath?.invoke(item),
|
||||
rdgFlap = itemRdgFlap?.invoke(item) == true,
|
||||
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
|
||||
showQwinsta = itemQwinsta?.invoke(item) == true,
|
||||
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
|
||||
qwinstaLoading = qwinstaLoading,
|
||||
showTerminate = itemTerminate?.invoke(item) == true,
|
||||
onTerminateClick = onTerminate?.let { handler -> { handler(item) } },
|
||||
terminateLoading = terminateLoadingId == itemKey(item),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -439,11 +511,15 @@ private fun ListRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
rdgAccessPath: String? = null,
|
||||
rdgFlap: Boolean = false,
|
||||
rdgFlapPairId: Long? = null,
|
||||
showQwinsta: Boolean = false,
|
||||
onQwinstaClick: (() -> Unit)? = null,
|
||||
qwinstaLoading: Boolean = false,
|
||||
showTerminate: Boolean = false,
|
||||
onTerminateClick: (() -> Unit)? = null,
|
||||
terminateLoading: Boolean = false,
|
||||
) {
|
||||
Card(
|
||||
Modifier
|
||||
@@ -461,6 +537,9 @@ private fun ListRow(
|
||||
.clickable(onClick = onClick),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (!rdgAccessPath.isNullOrBlank()) {
|
||||
RdgAccessBadge(path = rdgAccessPath)
|
||||
}
|
||||
if (rdgFlap) {
|
||||
RdgFlapBadge(pairEventId = rdgFlapPairId)
|
||||
}
|
||||
@@ -471,7 +550,14 @@ private fun ListRow(
|
||||
OutlinedButton(
|
||||
enabled = !qwinstaLoading,
|
||||
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 =
|
||||
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
||||
|
||||
fun rdgFlapQwinstaEventId(event: EventSummary): Long? =
|
||||
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
|
||||
|
||||
fun hasRdgFlapUi(event: EventDetail): Boolean =
|
||||
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
||||
|
||||
fun rdgFlapQwinstaEventId(event: EventDetail): Long? =
|
||||
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
|
||||
fun hasRdgAccessPath(event: EventSummary): Boolean =
|
||||
!event.rdgAccessPath.isNullOrBlank()
|
||||
|
||||
fun hasRdgAccessPath(event: EventDetail): Boolean =
|
||||
!event.rdgAccessPath.isNullOrBlank()
|
||||
|
||||
/** Как SAC rdgQwinstaEventId: кнопка только при rdg_qwinsta_enabled от API. */
|
||||
fun rdgQwinstaEventId(event: EventSummary): Long? {
|
||||
if (!event.rdgQwinstaEnabled) return null
|
||||
return event.rdgFlapQwinstaEventId ?: event.id
|
||||
}
|
||||
|
||||
fun rdgQwinstaEventId(event: EventDetail): Long? {
|
||||
if (!event.rdgQwinstaEnabled) return null
|
||||
return event.rdgFlapQwinstaEventId ?: event.id
|
||||
}
|
||||
|
||||
/** @deprecated use rdgQwinstaEventId */
|
||||
fun rdgFlapQwinstaEventId(event: EventSummary): Long? = rdgQwinstaEventId(event)
|
||||
|
||||
/** @deprecated use rdgQwinstaEventId */
|
||||
fun rdgFlapQwinstaEventId(event: EventDetail): Long? = rdgQwinstaEventId(event)
|
||||
|
||||
fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessionRow> {
|
||||
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.kalinamall.seaca.ui.util
|
||||
|
||||
import ru.kalinamall.seaca.data.EventDetail
|
||||
import ru.kalinamall.seaca.data.EventSummary
|
||||
|
||||
private val LINUX_SESSION_EVENT_TYPES = setOf(
|
||||
"session.logind.new",
|
||||
"ssh.login.success",
|
||||
"privilege.sudo.command",
|
||||
)
|
||||
|
||||
private val WINDOWS_SESSION_EVENT_TYPES = setOf("rdp.login.success")
|
||||
|
||||
fun eventSupportsSessionTerminate(event: EventSummary): Boolean =
|
||||
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
|
||||
!event.sessionTerminated
|
||||
|
||||
fun eventSupportsSessionTerminate(event: EventDetail): Boolean =
|
||||
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
|
||||
!event.sessionTerminated
|
||||
|
||||
fun sessionTerminateLabel(event: EventSummary): String =
|
||||
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
|
||||
|
||||
fun sessionTerminateLabel(event: EventDetail): String =
|
||||
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
|
||||
@@ -0,0 +1,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 |
|
||||
@@ -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 |
|
||||
@@ -1,87 +1,46 @@
|
||||
# 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)
|
||||
|
||||
## Связанные репозитории
|
||||
## Возможности
|
||||
|
||||
| Репозиторий | Роль |
|
||||
|-------------|------|
|
||||
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
|
||||
| ssh-monitor | Агент Linux |
|
||||
| RDP-login-monitor | Агент Windows |
|
||||
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||
- Ack / Resolve проблем (monitor+)
|
||||
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||
- Биометрия при запуске и возврате в приложение
|
||||
- Формат даты, экран «Моё устройство»
|
||||
|
||||
| Репозиторий | URL |
|
||||
|-------------|-----|
|
||||
| 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 |
|
||||
## Стек
|
||||
## Нет в приложении (только веб-SAC)
|
||||
|
||||
| Слой | Технология |
|
||||
|------|------------|
|
||||
| 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) |
|
||||
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|
||||
|
||||
## Что есть в приложении
|
||||
## Привязка
|
||||
|
||||
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
|
||||
- Pull-to-refresh на обзоре и в списках
|
||||
- Фильтры событий и проблем (severity, status)
|
||||
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
|
||||
- Ack / Resolve для роли `operator` и выше
|
||||
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
|
||||
- Выбор звука уведомления: системный или свой (ringtone picker)
|
||||
- Биометрическая блокировка при **запуске** и при возврате в приложение
|
||||
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
|
||||
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
|
||||
1. Админ: мобильные устройства + код `sacmob_…`
|
||||
2. URL API SAC + код + логин/пароль в Seaca
|
||||
3. FCM-токен регистрируется автоматически
|
||||
|
||||
## Чего нет в приложении (только веб)
|
||||
|
||||
- Управление пользователями 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) | План разработки |
|
||||
[docs/operator-guide.md](docs/operator-guide.md)
|
||||
|
||||
## Сборка
|
||||
|
||||
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
|
||||
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
|
||||
|
||||
```bash
|
||||
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
|
||||
./gradlew assembleDebug
|
||||
# Windows:
|
||||
gradlew.bat assembleDebug
|
||||
```
|
||||
|
||||
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
|
||||
## Репозитории
|
||||
|
||||
## Лицензия
|
||||
|
||||
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
|
||||
| Репозиторий | URL |
|
||||
|-------------|-----|
|
||||
| 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 |
|
||||
|
||||
@@ -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