diff --git a/.gitignore b/.gitignore
index 3fbf6e3..a13d5e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,6 @@ app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt
.env
.env.*
-# Cursor (не в git — см. repo-policy)
+# Cursor (не в git)
.cursor/
.cursorignore
diff --git a/README.md b/README.md
index 001c3e6..b62c3d7 100644
--- a/README.md
+++ b/README.md
@@ -1,87 +1,43 @@
# 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.11` (versionCode 25)
-## Связанные репозитории
+## Возможности
-| Репозиторий | Роль |
-|-------------|------|
-| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
-| ssh-monitor | Агент Linux |
-| RDP-login-monitor | Агент Windows |
+- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
+- Push (FCM), режимы звука, выбор ringtone
+- Ack / Resolve проблем (monitor+)
+- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
+- Биометрия при запуске и возврате в приложение
+- Формат даты, экран «Моё устройство»
-| Репозиторий | 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) · SAC [docs/seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md)
## Сборка
-Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
+JDK 17+, `local.properties`. Опционально `app/google-services.json` для 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 (уточняется владельцем проекта).
+| Репозиторий | Роль |
+|-------------|------|
+| security-alert-center | Сервер API |
+| ssh-monitor / RDP-login-monitor | Агенты |
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9b9121c..9b0da79 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca"
minSdk = 26
targetSdk = 35
- versionCode = 21
- versionName = "0.5.7"
+ versionCode = 25
+ versionName = "0.5.11"
}
buildTypes {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 20b3375..7a175bd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -38,5 +38,15 @@
+
+
+
+
diff --git a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt
index 004456c..8f57ddd 100644
--- a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt
@@ -1,13 +1,21 @@
package ru.kalinamall.seaca
import android.app.Application
+import kotlinx.coroutines.runBlocking
+import ru.kalinamall.seaca.data.SessionStore
import ru.kalinamall.seaca.push.NotificationHelper
-
import ru.kalinamall.seaca.push.NotificationSoundResolver
class SeacaApplication : Application() {
override fun onCreate() {
super.onCreate()
- NotificationHelper.ensureChannels(this, NotificationSoundResolver.defaultNotificationUri())
+ val store = SessionStore(this)
+ val settings = runBlocking {
+ NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
+ }
+ NotificationHelper.ensureChannels(
+ this,
+ NotificationSoundResolver.resolve(this, settings),
+ )
}
}
diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt
index 871f9d7..3b43364 100644
--- a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt
@@ -36,6 +36,15 @@ interface SacApi {
@GET("api/v1/events/{id}")
suspend fun getEvent(@Path("id") id: Long): EventDetail
+ @POST("api/v1/events/{id}/actions/qwinsta")
+ suspend fun postEventQwinsta(@Path("id") id: Long): AgentCommandResponse
+
+ @POST("api/v1/events/{id}/actions/logoff")
+ suspend fun postEventLogoff(
+ @Path("id") id: Long,
+ @Body body: LogoffActionRequest,
+ ): AgentCommandResponse
+
@GET("api/v1/problems")
suspend fun listProblems(
@Query("page") page: Int = 1,
diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt
index 793a3f0..4d8b68c 100644
--- a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt
@@ -58,6 +58,9 @@ data class EventSummary(
val title: String,
val summary: String,
@SerialName("actor_user") val actorUser: String? = null,
+ @SerialName("rdg_flap") val rdgFlap: Boolean = false,
+ @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
+ @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
)
@Serializable
@@ -80,6 +83,10 @@ data class EventDetail(
val severity: String,
val title: String,
val summary: String,
+ @SerialName("actor_user") val actorUser: String? = null,
+ @SerialName("rdg_flap") val rdgFlap: Boolean = false,
+ @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
+ @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
val details: JsonElement? = null,
val raw: JsonElement? = null,
val payload: JsonElement? = null,
@@ -192,6 +199,23 @@ data class ProblemActionResponse(
val status: String,
)
+@Serializable
+data class LogoffActionRequest(
+ @SerialName("session_id") val sessionId: Int,
+)
+
+@Serializable
+data class AgentCommandResponse(
+ @SerialName("command_uuid") val commandUuid: String,
+ @SerialName("command_type") val commandType: String,
+ val status: String,
+ @SerialName("result_stdout") val resultStdout: String? = null,
+ @SerialName("result_stderr") val resultStderr: String? = null,
+ val target: String? = null,
+ @SerialName("client_hostname") val clientHostname: String? = null,
+ @SerialName("internal_ip") val internalIp: String? = null,
+)
+
@Serializable
data class ApiErrorBody(
val detail: String? = null,
diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt
index 2a4347a..a36244b 100644
--- a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt
@@ -117,7 +117,7 @@ class SacRepository(context: Context) {
}
val okhttp = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
- .readTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS)
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
.addInterceptor(authInterceptor)
diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt
index 23d7262..703dfe3 100644
--- a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt
@@ -13,36 +13,47 @@ import ru.kalinamall.seaca.MainActivity
import ru.kalinamall.seaca.R
object NotificationHelper {
- const val CHANNEL_DEFAULT = "sac_default"
- const val CHANNEL_HIGH = "sac_high"
- const val CHANNEL_CRITICAL = "sac_critical"
- const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
- const val CHANNEL_HIGH_SILENT = "sac_high_silent"
- const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
+ private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
+ private const val CHANNEL_HIGH_SILENT = "sac_high_silent"
+ private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
+
+ private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical")
- private val soundChannelIds = listOf(CHANNEL_DEFAULT, CHANNEL_HIGH, CHANNEL_CRITICAL)
private var appliedSoundKey: String? = null
+ private var soundChannelSuffix: String = "sys"
+ private var channelDefault = "sac_default_sys"
+ private var channelHigh = "sac_high_sys"
+ private var channelCritical = "sac_critical_sys"
fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val soundKey = soundUri.toString()
val mgr = context.getSystemService(NotificationManager::class.java)
if (soundKey != appliedSoundKey) {
- soundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
+ legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
+ if (appliedSoundKey != null) {
+ mgr.deleteNotificationChannel(channelDefault)
+ mgr.deleteNotificationChannel(channelHigh)
+ mgr.deleteNotificationChannel(channelCritical)
+ }
appliedSoundKey = soundKey
+ soundChannelSuffix = soundKey.hashCode().toUInt().toString(16).take(8)
+ channelDefault = "sac_default_$soundChannelSuffix"
+ channelHigh = "sac_high_$soundChannelSuffix"
+ channelCritical = "sac_critical_$soundChannelSuffix"
}
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
mgr.createNotificationChannel(
- soundChannel(context, CHANNEL_DEFAULT, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
+ soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
- soundChannel(context, CHANNEL_HIGH, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
+ soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
- soundChannel(context, CHANNEL_CRITICAL, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
+ soundChannel(context, channelCritical, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
)
mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent))
mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent))
@@ -106,9 +117,9 @@ object NotificationHelper {
.setPriority(
when {
silent -> NotificationCompat.PRIORITY_DEFAULT
- channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT ->
+ channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT ->
NotificationCompat.PRIORITY_MAX
- channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT ->
+ channel == channelHigh || channel == CHANNEL_HIGH_SILENT ->
NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
},
@@ -132,9 +143,9 @@ object NotificationHelper {
}
}
return when (severity?.lowercase()) {
- "critical" -> CHANNEL_CRITICAL
- "high" -> CHANNEL_HIGH
- else -> CHANNEL_DEFAULT
+ "critical" -> channelCritical
+ "high" -> channelHigh
+ else -> channelDefault
}
}
}
diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt
index cdae605..5cfa8b1 100644
--- a/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt
@@ -5,19 +5,37 @@ import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
+import androidx.core.content.FileProvider
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
+import ru.kalinamall.seaca.data.SessionStore
+import java.io.File
object NotificationSoundResolver {
+ private const val CUSTOM_SOUND_FILE = "custom_notification_sound"
+
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
return when (settings.source) {
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
NotificationSoundSource.CUSTOM -> {
- settings.customUri?.let { Uri.parse(it) } ?: defaultNotificationUri()
+ playableCustomUri(context, settings.customUri) ?: defaultNotificationUri()
}
}
}
+ suspend fun normalizeStoredSettings(context: Context, store: SessionStore): NotificationSoundSettings {
+ val settings = store.getNotificationSoundSettings()
+ if (settings.source != NotificationSoundSource.CUSTOM || settings.customUri.isNullOrBlank()) {
+ return settings
+ }
+ if (isInternalSoundUri(context, settings.customUri)) {
+ return settings
+ }
+ val imported = importCustomSound(context, Uri.parse(settings.customUri)) ?: return settings
+ store.setNotificationSound(NotificationSoundSource.CUSTOM, imported)
+ return store.getNotificationSoundSettings()
+ }
+
fun defaultNotificationUri(): Uri =
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
@@ -30,15 +48,62 @@ object NotificationSoundResolver {
}
}
- fun persistCustomUri(context: Context, uri: Uri?) {
- if (uri == null || uri.scheme != ContentResolver.SCHEME_CONTENT) return
+ /**
+ * Copies picker/system URI into app storage so FCM in a fresh process can read it.
+ * Returns stored URI string (FileProvider or android.resource).
+ */
+ fun importCustomSound(context: Context, source: Uri): String? {
+ if (ContentResolver.SCHEME_ANDROID_RESOURCE == source.scheme) {
+ return source.toString()
+ }
+ if (defaultNotificationUri().toString() == source.toString()) {
+ return source.toString()
+ }
+ persistCustomUri(context, source)
+ val dest = File(context.filesDir, CUSTOM_SOUND_FILE)
+ return try {
+ context.contentResolver.openInputStream(source)?.use { input ->
+ dest.outputStream().use { output -> input.copyTo(output) }
+ } ?: return null
+ internalFileUri(context, dest).toString()
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun playableCustomUri(context: Context, stored: String?): Uri? {
+ if (stored.isNullOrBlank()) return null
+ val parsed = Uri.parse(stored)
+ if (isInternalSoundUri(context, stored)) {
+ val file = File(context.filesDir, CUSTOM_SOUND_FILE)
+ return if (file.isFile) internalFileUri(context, file) else null
+ }
+ if (ContentResolver.SCHEME_ANDROID_RESOURCE == parsed.scheme) {
+ return parsed
+ }
+ return importCustomSound(context, parsed)?.let { Uri.parse(it) }
+ }
+
+ private fun isInternalSoundUri(context: Context, stored: String): Boolean {
+ val parsed = Uri.parse(stored)
+ return parsed.authority == fileProviderAuthority(context)
+ }
+
+ private fun internalFileUri(context: Context, file: File): Uri =
+ FileProvider.getUriForFile(context, fileProviderAuthority(context), file)
+
+ private fun fileProviderAuthority(context: Context): String =
+ "${context.packageName}.fileprovider"
+
+ private fun persistCustomUri(context: Context, uri: Uri) {
+ if (uri.scheme != ContentResolver.SCHEME_CONTENT) return
try {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (_: SecurityException) {
- // Picker may not grant persistable permission for some URIs.
+ // Best-effort; internal copy is the reliable path.
}
}
}
diff --git a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt
index fc5f636..9db72a1 100644
--- a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt
@@ -30,7 +30,9 @@ class SeacaMessagingService : FirebaseMessagingService() {
val soundUri = if (silent) {
NotificationSoundResolver.defaultNotificationUri()
} else {
- val settings = runBlocking { repo.session.getNotificationSoundSettings() }
+ val settings = runBlocking {
+ NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session)
+ }
NotificationSoundResolver.resolve(this, settings)
}
NotificationHelper.show(
diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt
new file mode 100644
index 0000000..a92e416
--- /dev/null
+++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt
@@ -0,0 +1,43 @@
+package ru.kalinamall.seaca.ui.components
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+private val RdgFlapText = Color(0xFFF0C674)
+private val RdgFlapBackground = Color(0x1FF0C674)
+private val RdgFlapBorder = Color(0x73F0C674)
+
+@Composable
+fun RdgFlapBadge(
+ modifier: Modifier = Modifier,
+ pairEventId: Long? = null,
+) {
+ val label = buildString {
+ append("RDG FLAP")
+ if (pairEventId != null) append(" #$pairEventId")
+ }
+ Surface(
+ modifier = modifier
+ .border(1.dp, RdgFlapBorder, RoundedCornerShape(4.dp)),
+ shape = RoundedCornerShape(4.dp),
+ color = RdgFlapBackground,
+ ) {
+ Text(
+ text = label,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ color = RdgFlapText,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.3.sp,
+ )
+ }
+}
diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt
new file mode 100644
index 0000000..7023e6c
--- /dev/null
+++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt
@@ -0,0 +1,334 @@
+package ru.kalinamall.seaca.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import ru.kalinamall.seaca.data.EventDetail
+import ru.kalinamall.seaca.data.EventSummary
+import ru.kalinamall.seaca.data.LogoffActionRequest
+import ru.kalinamall.seaca.data.SacRepository
+import ru.kalinamall.seaca.ui.util.QwinstaSessionRow
+import ru.kalinamall.seaca.ui.util.agentCommandError
+import ru.kalinamall.seaca.ui.util.parseQwinstaSessions
+import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
+
+class RdgQwinstaController(private val repo: SacRepository) {
+ var open by mutableStateOf(false)
+ private set
+ var loading by mutableStateOf(false)
+ private set
+ var error by mutableStateOf(null)
+ private set
+ var meta by mutableStateOf(null)
+ private set
+ var stdout by mutableStateOf(null)
+ private set
+ var sessions by mutableStateOf>(emptyList())
+ private set
+ var logoffSessionId by mutableStateOf(null)
+ private set
+ var targetEventId by mutableStateOf(null)
+ private set
+ private var actorUser: String? = null
+
+ fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
+ val targetId = rdgFlapQwinstaEventId(event) ?: return
+ targetEventId = targetId
+ actorUser = event.actorUser
+ open = true
+ runQwinsta(scope)
+ }
+
+ fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
+ val targetId = rdgFlapQwinstaEventId(event) ?: return
+ targetEventId = targetId
+ actorUser = event.actorUser
+ open = true
+ runQwinsta(scope)
+ }
+
+ fun dismiss() {
+ if (!loading && logoffSessionId == null) {
+ open = false
+ }
+ }
+
+ fun runQwinsta(scope: kotlinx.coroutines.CoroutineScope) {
+ val targetId = targetEventId ?: return
+ scope.launch {
+ loading = true
+ error = null
+ stdout = null
+ sessions = emptyList()
+ try {
+ val cmd = repo.withAuth { it.postEventQwinsta(targetId) }
+ meta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target)
+ .joinToString(" · ")
+ .ifBlank { null }
+ val cmdError = agentCommandError(cmd)
+ if (cmdError != null) {
+ error = cmdError
+ } else {
+ val out = cmd.resultStdout.orEmpty()
+ stdout = out.ifBlank { null }
+ sessions = parseQwinstaSessions(out, actorUser)
+ }
+ } catch (e: Exception) {
+ error = if (e is HttpException) SacRepository.parseError(e) else e.message
+ } finally {
+ loading = false
+ }
+ }
+ }
+
+ fun logoff(sessionId: Int, scope: kotlinx.coroutines.CoroutineScope, onDone: () -> Unit = {}) {
+ val targetId = targetEventId ?: return
+ scope.launch {
+ logoffSessionId = sessionId
+ error = null
+ try {
+ val cmd = repo.withAuth {
+ it.postEventLogoff(targetId, LogoffActionRequest(sessionId))
+ }
+ val cmdError = agentCommandError(cmd)
+ if (cmdError != null) {
+ error = cmdError
+ } else {
+ runQwinsta(scope)
+ }
+ } catch (e: Exception) {
+ error = if (e is HttpException) SacRepository.parseError(e) else e.message
+ } finally {
+ logoffSessionId = null
+ onDone()
+ }
+ }
+ }
+}
+
+@Composable
+fun rememberRdgQwinstaController(repo: SacRepository): RdgQwinstaController =
+ remember(repo) { RdgQwinstaController(repo) }
+
+@Composable
+fun RdgQwinstaDialog(
+ controller: RdgQwinstaController,
+ modifier: Modifier = Modifier,
+) {
+ if (!controller.open) return
+ val scope = rememberCoroutineScope()
+ var confirmSessionId by remember { mutableIntStateOf(-1) }
+ var manualSessionId by remember { mutableStateOf("") }
+
+ if (confirmSessionId >= 0) {
+ AlertDialog(
+ onDismissRequest = { confirmSessionId = -1 },
+ title = { Text("Завершить сеанс?") },
+ text = { Text("Отправить logoff для сеанса ID $confirmSessionId на клиентском ПК?") },
+ confirmButton = {
+ Button(
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ onClick = {
+ val id = confirmSessionId
+ confirmSessionId = -1
+ controller.logoff(id, scope)
+ },
+ ) { Text("logoff") }
+ },
+ dismissButton = {
+ TextButton(onClick = { confirmSessionId = -1 }) { Text("Отмена") }
+ },
+ )
+ }
+
+ Dialog(
+ onDismissRequest = { controller.dismiss() },
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ Modifier
+ .padding(16.dp)
+ .heightIn(max = 520.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text("qwinsta / logoff", style = MaterialTheme.typography.titleLarge)
+ controller.meta?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ when {
+ controller.loading -> {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ CircularProgressIndicator()
+ Text("Ожидание ответа…")
+ }
+ }
+ controller.error != null -> {
+ Text(controller.error!!, color = MaterialTheme.colorScheme.error)
+ }
+ }
+
+ if (controller.sessions.isNotEmpty()) {
+ Text(
+ "Сеансы — нажмите logoff, чтобы оборвать зависшую сессию:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ controller.sessions.forEach { s ->
+ SessionLogoffRow(
+ session = s,
+ logoffEnabled = !controller.loading && controller.logoffSessionId == null,
+ logoffInProgress = controller.logoffSessionId == s.id,
+ onLogoff = { confirmSessionId = s.id },
+ )
+ }
+ } else if (!controller.loading && controller.stdout != null) {
+ Text(
+ "Сеансы не распознаны автоматически. Введите ID из вывода ниже:",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = manualSessionId,
+ onValueChange = { manualSessionId = it.filter { ch -> ch.isDigit() } },
+ label = { Text("ID сеанса") },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ Button(
+ enabled = manualSessionId.isNotBlank() && !controller.loading && controller.logoffSessionId == null,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ onClick = {
+ manualSessionId.toIntOrNull()?.let { confirmSessionId = it }
+ },
+ ) { Text("logoff") }
+ }
+ }
+
+ controller.stdout?.let { raw ->
+ Text("Вывод qwinsta", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
+ Text(raw, style = MaterialTheme.typography.bodySmall)
+ }
+ if (controller.stdout == null && !controller.loading && controller.error == null) {
+ Text("Нет вывода", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(
+ enabled = !controller.loading && controller.logoffSessionId == null,
+ onClick = { controller.dismiss() },
+ ) { Text("Закрыть") }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SessionLogoffRow(
+ session: QwinstaSessionRow,
+ logoffEnabled: Boolean,
+ logoffInProgress: Boolean,
+ onLogoff: () -> Unit,
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(session.sessionName, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ "${session.userName} · ID ${session.id} · ${session.state}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (logoffInProgress) {
+ CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp))
+ } else {
+ Button(
+ enabled = logoffEnabled,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ onClick = onLogoff,
+ ) { Text("logoff") }
+ }
+ }
+ }
+}
+
+@Composable
+fun RdgQwinstaButton(
+ controller: RdgQwinstaController,
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ OutlinedButton(
+ enabled = enabled && !controller.loading,
+ onClick = onClick,
+ modifier = modifier,
+ ) {
+ Text(if (controller.loading) "qwinsta…" else "qwinsta")
+ }
+}
diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt
index 358f089..43ba56f 100644
--- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt
@@ -17,7 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.TextButton
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -55,7 +58,13 @@ import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.ProblemDetail
+import ru.kalinamall.seaca.data.LogoffActionRequest
import ru.kalinamall.seaca.data.SacRepository
+import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
+import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
+import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
+import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
+import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
import ru.kalinamall.seaca.ui.RingtonePicker
@@ -78,6 +87,8 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var data by remember { mutableStateOf(null) }
var error by remember { mutableStateOf(null) }
var loading by remember { mutableStateOf(true) }
+ val scope = rememberCoroutineScope()
+ val qwinsta = rememberRdgQwinstaController(repo)
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
@@ -95,6 +106,17 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
val d = data ?: return@DetailFrame
+ 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),
+ )
+ }
+ }
Field("Severity", d.severity)
Field(
"Тип",
@@ -110,6 +132,8 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
EventDetailsSection(d.type, d.details, d.payload)
}
}
+
+ RdgQwinstaDialog(qwinsta)
}
@Composable
@@ -448,9 +472,12 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
)
}
- LaunchedEffect(soundSettings) {
+ LaunchedEffect(soundSettings, notificationMode) {
if (notificationMode == NotificationMode.PUSH_SOUND) {
- applySoundChannels(soundSettings)
+ val normalized = repo.session.let {
+ NotificationSoundResolver.normalizeStoredSettings(context, it)
+ }
+ applySoundChannels(normalized)
}
}
@@ -462,11 +489,15 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
try {
RingtonePicker.launch(activity, currentUri) { uri ->
- NotificationSoundResolver.persistCustomUri(context, uri)
if (uri == null) {
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
} else {
- repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, uri.toString())
+ val stored = NotificationSoundResolver.importCustomSound(context, uri)
+ if (stored == null) {
+ Toast.makeText(context, "Не удалось сохранить звук", Toast.LENGTH_LONG).show()
+ return@launch
+ }
+ repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, stored)
}
applySoundChannels(repo.session.getNotificationSoundSettings())
}
diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt
index 6e07855..9e82beb 100644
--- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt
+++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt
@@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -46,8 +47,14 @@ 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.RdgFlapBadge
+import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
+import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
+import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes
+import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
+import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов",
@@ -62,6 +69,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
var loading by remember { mutableStateOf(false) }
var refreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
+ val qwinsta = rememberRdgQwinstaController(repo)
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
@@ -121,12 +129,22 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
- Text(
- dashboardRecentEventLine(e),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.weight(1f),
- )
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (hasRdgFlapUi(e)) {
+ RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
+ }
+ Text(
+ dashboardRecentEventLine(e),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (rdgFlapQwinstaEventId(e) != null) {
+ RdgQwinstaButton(
+ controller = qwinsta,
+ onClick = { qwinsta.openFor(e, scope) },
+ )
+ }
+ }
Text(
SacDateTimes.format(e.occurredAt, dateMode),
style = MaterialTheme.typography.bodySmall,
@@ -140,6 +158,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
}
}
}
+ RdgQwinstaDialog(qwinsta)
}
private fun dashboardRecentEventLine(event: EventSummary): String {
@@ -171,6 +190,8 @@ private fun StatCard(label: String, value: String) {
@Composable
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var hostname by remember { mutableStateOf("") }
+ val scope = rememberCoroutineScope()
+ val qwinsta = rememberRdgQwinstaController(repo)
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
@@ -191,6 +212,11 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
itemKey = EventSummary::id,
itemLabel = { "${it.severity} · ${it.title}" },
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
+ itemRdgFlap = { hasRdgFlapUi(it) },
+ itemRdgFlapPairId = { it.rdgFlapPairEventId },
+ itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
+ onQwinsta = { qwinsta.openFor(it, scope) },
+ qwinstaLoading = qwinsta.loading,
onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"),
hostnameFilter = hostname,
@@ -205,6 +231,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
)
},
)
+ RdgQwinstaDialog(qwinsta)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -327,6 +354,11 @@ private fun PagedListScreen(
itemSub: (T) -> String,
onOpen: (Long) -> Unit,
severities: List,
+ itemRdgFlap: ((T) -> Boolean)? = null,
+ itemRdgFlapPairId: ((T) -> Long?)? = null,
+ itemQwinsta: ((T) -> Boolean)? = null,
+ onQwinsta: ((T) -> Unit)? = null,
+ qwinstaLoading: Boolean = false,
filterLabel: String = "Severity",
hostnameFilter: String = "",
headerContent: @Composable () -> Unit = {},
@@ -385,7 +417,16 @@ private fun PagedListScreen(
) {
LazyColumn {
items(items, key = { itemKey(it) }) { item ->
- ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(item)) }
+ ListRow(
+ title = itemLabel(item),
+ subtitle = itemSub(item),
+ onClick = { onOpen(itemKey(item)) },
+ rdgFlap = itemRdgFlap?.invoke(item) == true,
+ rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
+ showQwinsta = itemQwinsta?.invoke(item) == true,
+ onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
+ qwinstaLoading = qwinstaLoading,
+ )
}
}
}
@@ -394,20 +435,44 @@ private fun PagedListScreen(
}
@Composable
-private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) {
+private fun ListRow(
+ title: String,
+ subtitle: String,
+ onClick: () -> Unit,
+ rdgFlap: Boolean = false,
+ rdgFlapPairId: Long? = null,
+ showQwinsta: Boolean = false,
+ onQwinstaClick: (() -> Unit)? = null,
+ qwinstaLoading: Boolean = false,
+) {
Card(
Modifier
.fillMaxWidth()
- .padding(vertical = 4.dp)
- .clickable(onClick = onClick),
+ .padding(vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
- Column(Modifier.padding(12.dp)) {
- Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
- Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ if (rdgFlap) {
+ RdgFlapBadge(pairEventId = rdgFlapPairId)
+ }
+ Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (showQwinsta && onQwinstaClick != null) {
+ OutlinedButton(
+ enabled = !qwinstaLoading,
+ onClick = onQwinstaClick,
+ ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") }
+ }
}
}
}
diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt
new file mode 100644
index 0000000..a85d1d3
--- /dev/null
+++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt
@@ -0,0 +1,71 @@
+package ru.kalinamall.seaca.ui.util
+
+import ru.kalinamall.seaca.data.EventDetail
+import ru.kalinamall.seaca.data.EventSummary
+
+data class QwinstaSessionRow(
+ val sessionName: String,
+ val userName: String,
+ val id: Int,
+ val state: String,
+)
+
+fun hasRdgFlapUi(event: EventSummary): Boolean =
+ event.rdgFlap || event.rdgFlapQwinstaEventId != null
+
+fun 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 parseQwinstaSessions(stdout: String, actorUser: String?): List {
+ val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
+ val rows = mutableListOf()
+ for (line in lines) {
+ if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue
+ val parts = line.split(Regex("\\s+"))
+ if (parts.size < 4) continue
+ val id = parts[2].toIntOrNull() ?: continue
+ val userName = parts[1]
+ if (!actorUser.isNullOrBlank()) {
+ val norm: (String) -> String = { it.replace(Regex("^B26\\\\", RegexOption.IGNORE_CASE), "").lowercase() }
+ if (!norm(userName).contains(norm(actorUser))) continue
+ }
+ rows.add(
+ QwinstaSessionRow(
+ sessionName = parts[0].removePrefix(">").trim(),
+ userName = userName,
+ id = id,
+ state = parts.drop(3).joinToString(" "),
+ ),
+ )
+ }
+ if (rows.isEmpty() && stdout.isNotBlank()) {
+ for (line in lines) {
+ if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue
+ val parts = line.split(Regex("\\s+"))
+ if (parts.size < 4) continue
+ val id = parts[2].toIntOrNull() ?: continue
+ rows.add(
+ QwinstaSessionRow(
+ sessionName = parts[0].removePrefix(">").trim(),
+ userName = parts[1],
+ id = id,
+ state = parts.drop(3).joinToString(" "),
+ ),
+ )
+ }
+ }
+ return rows
+}
+
+fun agentCommandError(cmd: ru.kalinamall.seaca.data.AgentCommandResponse): String? {
+ if (cmd.status != "failed") return null
+ return cmd.resultStderr?.takeIf { it.isNotBlank() }
+ ?: cmd.resultStdout?.takeIf { it.isNotBlank() }
+ ?: "Команда завершилась с ошибкой"
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..4698997
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index baa4a09..2cc9dc9 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# Roadmap — Seaca
-Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в репозитории **security-alert-center**, фаза **v0.6 — Mobile**.
+Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center), фаза **v0.6 — Mobile**.
---
@@ -59,7 +59,7 @@
- [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm`
- [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
- [x] Notification channels по severity (critical / high / default)
-- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+) — **после первого APK**
+- [x] RDG flap: qwinsta/logoff через SAC API (карточка события)
- [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
diff --git a/docs/operator-guide.md b/docs/operator-guide.md
index b30bbd2..4a46c0d 100644
--- a/docs/operator-guide.md
+++ b/docs/operator-guide.md
@@ -1,46 +1,39 @@
-# Seaca — руководство оператора
+# Seaca — оператор
-## Первая настройка
+## Подключение
-1. Получите от администратора:
- - **URL API SAC** для мобильного приложения (HTTPS, путь `/api/v1/…`);
- - код регистрации `sacmob_…`;
- - при необходимости — логин и пароль SAC.
-2. Установите APK Seaca.
-3. На экране подключения введите данные → **Подключиться**.
-4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
-
-После успеха код больше не нужен — приложение запоминает сессию.
+URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату.
## Разделы
| Раздел | Содержание |
|--------|------------|
-| Обзор | Сводка за 24 ч, открытые проблемы; потяните вниз для обновления |
-| События | Лента с фильтром по severity |
-| Проблемы | Инциденты; для operator и выше — подтвердить / закрыть |
-| Хосты | Агенты ssh-monitor / RDP-login-monitor |
-| Отчёты | Суточные отчёты SSH и RDP |
-| Моё устройство | Сервер, пользователь, режим push, звук, формат даты/времени, выход |
+| Обзор | Сводка 24 ч; pull-to-refresh |
+| События | Лента; **RDG flap** в заголовке |
+| Проблемы | Ack / Resolve (monitor+) |
+| Хосты | Агенты, heartbeat |
+| Отчёты | `report.daily.ssh` / `report.daily.rdp` |
+| Моё устройство | Push, звук, дата, выход |
-## Push-уведомления
+## RDG flap — qwinsta / logoff
-Настраиваются на экране **Моё устройство**:
+Пара RD Gateway **302→303** за несколько секунд:
-| Режим | Поведение |
-|-------|-----------|
-| Push и звук | Уведомление со звуком (системным или выбранным) |
-| Push без звука | Только banner, без звука |
-| Без push и звука | Данные в приложении по запросу |
+1. Событие **302** или **303** (метка **RDG flap**).
+2. **qwinsta / logoff** → `POST /api/v1/events/{id}/actions/qwinsta` на SAC.
+3. SAC: WinRM **qwinsta** на клиентский ПК (`internal_ip`).
+4. В диалоге — сессии, **logoff** по ID.
-Нажатие на уведомление открывает карточку события или проблемы.
+Нужен **Windows domain admin** в настройках SAC (WinRM).
+
+## Push
+
+Режимы на экране «Моё устройство». Тап — карточка события или problem.
## Блокировка
-При **запуске** приложения и при возврате из фона может запрашиваться отпечаток / PIN устройства (если включено в системе).
+Биометрия/PIN при запуске и возврате в приложение.
## Потеря доступа
-Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC.
-
-Подробности для админов — в репозитории **security-alert-center**, файл `docs/seaca-mobile.md`.
+Новый код или снятие отзыва устройства в веб-SAC.
diff --git a/scripts/readme/github.md b/scripts/readme/github.md
deleted file mode 100644
index 6c205a7..0000000
--- a/scripts/readme/github.md
+++ /dev/null
@@ -1,87 +0,0 @@
-# Seaca
-
-**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
-
-Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
-
-## Связанные репозитории
-
-| Репозиторий | Роль |
-|-------------|------|
-| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
-| ssh-monitor | Агент Linux |
-| RDP-login-monitor | Агент Windows |
-
-| Репозиторий | URL |
-|-------------|-----|
-| 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 |
-## Стек
-
-| Слой | Технология |
-|------|------------|
-| UI | Kotlin, Jetpack Compose, Material 3 |
-| Сеть | Retrofit / OkHttp, Kotlin Serialization |
-| Хранение | DataStore + Android Keystore (токены, URL сервера) |
-| Push | **Firebase Cloud Messaging (FCM)** |
-| Минимум SDK | Android 8.0 (API 26) |
-
-## Что есть в приложении
-
-- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
-- Pull-to-refresh на обзоре и в списках
-- Фильтры событий и проблем (severity, status)
-- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
-- Ack / Resolve для роли `operator` и выше
-- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
-- Выбор звука уведомления: системный или свой (ringtone picker)
-- Биометрическая блокировка при **запуске** и при возврате в приложение
-- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
-- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
-
-## Чего нет в приложении (только веб)
-
-- Управление пользователями SAC
-- Настройки каналов (Telegram, SMTP, webhook), severity, policy
-- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
-
-## Привязка телефона к серверу
-
-1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
-2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
-3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии).
-4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
-5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
-
-Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
-
-## Статус
-
-**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты)
-
-## Документация
-
-| Документ | Назначение |
-|----------|------------|
-| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
-| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
-
-## Сборка
-
-Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
-
-```bash
-# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
-./gradlew assembleDebug
-# Windows:
-gradlew.bat assembleDebug
-```
-
-APK: `app/build/outputs/apk/debug/app-debug.apk`
-
-Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
-
-## Лицензия
-
-Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
diff --git a/scripts/readme/home.md b/scripts/readme/home.md
deleted file mode 100644
index 2a7e0fe..0000000
--- a/scripts/readme/home.md
+++ /dev/null
@@ -1,87 +0,0 @@
-# Seaca
-
-**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
-
-Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
-
-## Связанные репозитории
-
-| Репозиторий | Роль |
-|-------------|------|
-| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
-| ssh-monitor | Агент Linux |
-| RDP-login-monitor | Агент Windows |
-
-| Репозиторий | URL |
-|-------------|-----|
-| 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 |
-## Стек
-
-| Слой | Технология |
-|------|------------|
-| UI | Kotlin, Jetpack Compose, Material 3 |
-| Сеть | Retrofit / OkHttp, Kotlin Serialization |
-| Хранение | DataStore + Android Keystore (токены, URL сервера) |
-| Push | **Firebase Cloud Messaging (FCM)** |
-| Минимум SDK | Android 8.0 (API 26) |
-
-## Что есть в приложении
-
-- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
-- Pull-to-refresh на обзоре и в списках
-- Фильтры событий и проблем (severity, status)
-- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
-- Ack / Resolve для роли `operator` и выше
-- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
-- Выбор звука уведомления: системный или свой (ringtone picker)
-- Биометрическая блокировка при **запуске** и при возврате в приложение
-- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
-- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
-
-## Чего нет в приложении (только веб)
-
-- Управление пользователями SAC
-- Настройки каналов (Telegram, SMTP, webhook), severity, policy
-- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
-
-## Привязка телефона к серверу
-
-1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
-2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
-3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии).
-4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
-5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
-
-Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
-
-## Статус
-
-**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты)
-
-## Документация
-
-| Документ | Назначение |
-|----------|------------|
-| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
-| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
-
-## Сборка
-
-Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
-
-```bash
-# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
-./gradlew assembleDebug
-# Windows:
-gradlew.bat assembleDebug
-```
-
-APK: `app/build/outputs/apk/debug/app-debug.apk`
-
-Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
-
-## Лицензия
-
-Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).