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 (уточняется владельцем проекта).