Compare commits
29 Commits
642d468005
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6398d48e06 | |||
| 3bb81e40af | |||
| d794d7dc48 | |||
| 47842e0ef3 | |||
| c78e11f265 | |||
| 0f05c3b2c3 | |||
| c0842eb84b | |||
| 477e5ea6ed | |||
| 6592f027a1 | |||
| c6433d6fd3 | |||
| 0884cc3d29 | |||
| 702b61726e | |||
| 16dd8ca3cc | |||
| 6a0758ffad | |||
| 363471c563 | |||
| c040b7a6c6 | |||
| 23d5cd271b | |||
| 4f9a241cea | |||
| 823ac4df57 | |||
| 2eda925101 | |||
| 9966c9f030 | |||
| 665625ecd8 | |||
| f912a4889a | |||
| 897d9e6820 | |||
| ac911a88eb | |||
| ece80dc723 | |||
| b3d7ed4132 | |||
| ff5080fffa | |||
| e55e9fdf16 |
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
# Android / Gradle
|
# Android / Gradle
|
||||||
*.iml
|
*.iml
|
||||||
.gradle/
|
.gradle/
|
||||||
/local.properties
|
/local.properties
|
||||||
@@ -27,3 +27,7 @@ app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt
|
|||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
|
# Cursor (не в git)
|
||||||
|
.cursor/
|
||||||
|
.cursorignore
|
||||||
|
|||||||
@@ -1,85 +1,54 @@
|
|||||||
# Seaca
|
# Seaca
|
||||||
|
|
||||||
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center) для Android.
|
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
|
**Версия:** `0.5.16` (versionCode 30)
|
||||||
|
|
||||||
## Связанные репозитории
|
## Возможности
|
||||||
|
|
||||||
| Репозиторий | Роль |
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
|-------------|------|
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
| [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
|
- Ack / Resolve проблем (monitor+)
|
||||||
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Агент Linux |
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Агент Windows |
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
|
- Биометрия при запуске и возврате в приложение
|
||||||
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
## Стек
|
## Нет в приложении (только веб-SAC)
|
||||||
|
|
||||||
| Слой | Технология |
|
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|
||||||
|------|------------|
|
|
||||||
| UI | Kotlin, Jetpack Compose, Material 3 |
|
|
||||||
| Сеть | Retrofit / OkHttp, Kotlin Serialization |
|
|
||||||
| Хранение | DataStore + Android Keystore (токены, URL сервера) |
|
|
||||||
| Push | **Firebase Cloud Messaging (FCM)** |
|
|
||||||
| Минимум SDK | Android 8.0 (API 26) |
|
|
||||||
|
|
||||||
### FCM — бесплатно?
|
## Привязка
|
||||||
|
|
||||||
**Да.** Firebase Cloud Messaging — бесплатный сервис Google для доставки push на Android (и не только). Платный тариф Firebase нужен для других продуктов (Analytics, Crashlytics на больших объёмах и т.д.); для стандартных data/notification push лимиты практически не ограничивают типичный SAC. Данные событий формируются на **вашем** SAC-сервере; через FCM уходит только компактный payload (заголовок, id, deep link).
|
1. Админ: мобильные устройства + код `sacmob_…`
|
||||||
|
2. URL API SAC + код + логин/пароль в Seaca
|
||||||
|
3. FCM-токен регистрируется автоматически
|
||||||
|
|
||||||
Альтернатива без Google (UnifiedPush) в этом проекте не планируется.
|
[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)
|
||||||
|
|
||||||
## Что есть в приложении
|
|
||||||
|
|
||||||
- Обзор, события, отчёты, проблемы, хосты (списки и карточки)
|
|
||||||
- Ack / Resolve для роли `operator` и выше
|
|
||||||
- Push с переходом в карточку события или problem
|
|
||||||
- Биометрическая блокировка при открытии (локально)
|
|
||||||
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
|
|
||||||
|
|
||||||
## Чего нет в приложении (только веб)
|
|
||||||
|
|
||||||
- Управление пользователями SAC
|
|
||||||
- Настройки каналов (Telegram, SMTP, webhook), severity, policy
|
|
||||||
- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
|
|
||||||
|
|
||||||
## Привязка телефона к серверу
|
|
||||||
|
|
||||||
1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
|
|
||||||
2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
|
|
||||||
3. Оператор в Seaca: URL сервера (или из QR) + код + логин/пароль (если код не привязан к предварительной сессии).
|
|
||||||
4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
|
|
||||||
5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
|
|
||||||
|
|
||||||
Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
**Версия:** `0.4.0` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM)
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
| Документ | Назначение |
|
|
||||||
|----------|------------|
|
|
||||||
| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
|
|
||||||
| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
|
|
||||||
| [SAC: seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) | Админ: коды, устройства, сессия |
|
|
||||||
| [SAC: seaca-fcm.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-fcm.md) | FCM на сервере |
|
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
|
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
|
||||||
|
|
||||||
|
**Debug** (ключ свой на каждом ПК):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Опционально: google-services.json в app/ (из Firebase, не в git) — для FCM
|
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
# Windows:
|
|
||||||
gradlew.bat assembleDebug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
|
||||||
Без `google-services.json` приложение собирается, но FCM не инициализируется (просмотр данных работает).
|
**Release** (один ключ на всех ПК — для обновлений поверх старой версии):
|
||||||
|
|
||||||
## Лицензия
|
1. Положить `seaca-release.jks` в корень репозитория (не в git).
|
||||||
|
2. Скопировать `secrets.properties.example` → `secrets.properties`, подставить пароль (ASCII).
|
||||||
|
3. `.\gradlew assembleRelease` — APK: `app/build/outputs/apk/release/app-release.apk`
|
||||||
|
|
||||||
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
|
## Репозитории
|
||||||
|
|
||||||
|
| Репозиторий | URL |
|
||||||
|
|-------------|-----|
|
||||||
|
| seaca | https://git.kalinamall.ru/PapaTramp/seaca |
|
||||||
|
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
|
||||||
|
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
|
||||||
|
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/rdp-login-monitor |
|
||||||
|
|||||||
+28
-2
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
@@ -5,6 +7,15 @@ plugins {
|
|||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val secretsFile = rootProject.file("secrets.properties")
|
||||||
|
val releaseSecrets = Properties().apply {
|
||||||
|
if (secretsFile.exists()) {
|
||||||
|
secretsFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val releaseKeystoreConfigured = secretsFile.exists() &&
|
||||||
|
releaseSecrets.getProperty("SEACA_STORE_FILE") != null
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "ru.kalinamall.seaca"
|
namespace = "ru.kalinamall.seaca"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -13,12 +24,26 @@ android {
|
|||||||
applicationId = "ru.kalinamall.seaca"
|
applicationId = "ru.kalinamall.seaca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 21
|
versionCode = 30
|
||||||
versionName = "0.5.7"
|
versionName = "0.5.16"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (releaseKeystoreConfigured) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = rootProject.file(releaseSecrets.getProperty("SEACA_STORE_FILE"))
|
||||||
|
storePassword = releaseSecrets.getProperty("SEACA_STORE_PASSWORD")
|
||||||
|
keyAlias = releaseSecrets.getProperty("SEACA_KEY_ALIAS")
|
||||||
|
keyPassword = releaseSecrets.getProperty("SEACA_KEY_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
if (releaseKeystoreConfigured) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
@@ -54,6 +79,7 @@ dependencies {
|
|||||||
implementation("androidx.activity:activity-compose:1.9.3")
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.3")
|
implementation("androidx.navigation:navigation-compose:2.8.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|||||||
@@ -38,5 +38,15 @@
|
|||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
package ru.kalinamall.seaca
|
package ru.kalinamall.seaca
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import ru.kalinamall.seaca.data.SessionStore
|
||||||
|
import ru.kalinamall.seaca.push.AppForegroundState
|
||||||
import ru.kalinamall.seaca.push.NotificationHelper
|
import ru.kalinamall.seaca.push.NotificationHelper
|
||||||
|
|
||||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||||
|
|
||||||
class SeacaApplication : Application() {
|
class SeacaApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
NotificationHelper.ensureChannels(this, NotificationSoundResolver.defaultNotificationUri())
|
AppForegroundState.bind(this)
|
||||||
|
val store = SessionStore(this)
|
||||||
|
val settings = runBlocking {
|
||||||
|
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
|
||||||
|
}
|
||||||
|
NotificationHelper.ensureChannels(
|
||||||
|
this,
|
||||||
|
NotificationSoundResolver.resolve(this, settings),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface SacApi {
|
|||||||
@Query("page_size") pageSize: Int = 30,
|
@Query("page_size") pageSize: Int = 30,
|
||||||
@Query("severity") severity: String? = null,
|
@Query("severity") severity: String? = null,
|
||||||
@Query("type") type: String? = null,
|
@Query("type") type: String? = null,
|
||||||
|
@Query("host_id") hostId: Long? = null,
|
||||||
|
@Query("include_hidden") includeHidden: Boolean? = null,
|
||||||
@Query("hostname") hostname: String? = null,
|
@Query("hostname") hostname: String? = null,
|
||||||
@Query("q") q: String? = null,
|
@Query("q") q: String? = null,
|
||||||
): EventListResponse
|
): EventListResponse
|
||||||
@@ -36,6 +38,30 @@ interface SacApi {
|
|||||||
@GET("api/v1/events/{id}")
|
@GET("api/v1/events/{id}")
|
||||||
suspend fun getEvent(@Path("id") id: Long): EventDetail
|
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
|
||||||
|
|
||||||
|
@POST("api/v1/events/{id}/actions/terminate-session")
|
||||||
|
suspend fun postEventTerminateSession(
|
||||||
|
@Path("id") id: Long,
|
||||||
|
@Body body: EventSessionTerminateRequest = EventSessionTerminateRequest(),
|
||||||
|
): SessionActionResponse
|
||||||
|
|
||||||
|
@POST("api/v1/hosts/{id}/actions/sessions/list")
|
||||||
|
suspend fun postHostSessionsList(@Path("id") id: Long): HostSessionsResponse
|
||||||
|
|
||||||
|
@POST("api/v1/hosts/{id}/actions/sessions/terminate")
|
||||||
|
suspend fun postHostSessionTerminate(
|
||||||
|
@Path("id") id: Long,
|
||||||
|
@Body body: HostSessionTerminateRequest,
|
||||||
|
): SessionActionResponse
|
||||||
|
|
||||||
@GET("api/v1/problems")
|
@GET("api/v1/problems")
|
||||||
suspend fun listProblems(
|
suspend fun listProblems(
|
||||||
@Query("page") page: Int = 1,
|
@Query("page") page: Int = 1,
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ data class EventSummary(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
@SerialName("actor_user") val actorUser: String? = null,
|
@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,
|
||||||
|
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
|
||||||
|
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
|
||||||
|
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -80,6 +86,13 @@ data class EventDetail(
|
|||||||
val severity: String,
|
val severity: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val summary: 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,
|
||||||
|
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
|
||||||
|
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
|
||||||
|
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
|
||||||
val details: JsonElement? = null,
|
val details: JsonElement? = null,
|
||||||
val raw: JsonElement? = null,
|
val raw: JsonElement? = null,
|
||||||
val payload: JsonElement? = null,
|
val payload: JsonElement? = null,
|
||||||
@@ -192,6 +205,60 @@ data class ProblemActionResponse(
|
|||||||
val status: String,
|
val status: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LogoffActionRequest(
|
||||||
|
@SerialName("session_id") val sessionId: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HostSessionItem(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
val user: String,
|
||||||
|
val tty: String? = null,
|
||||||
|
val state: String? = null,
|
||||||
|
@SerialName("source_ip") val sourceIp: String? = null,
|
||||||
|
@SerialName("session_name") val sessionName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HostSessionsResponse(
|
||||||
|
val ok: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val target: String? = null,
|
||||||
|
val sessions: List<HostSessionItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HostSessionTerminateRequest(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EventSessionTerminateRequest(
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionActionResponse(
|
||||||
|
val ok: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val target: String? = null,
|
||||||
|
val stdout: String? = null,
|
||||||
|
val stderr: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AgentCommandResponse(
|
||||||
|
@SerialName("command_uuid") val commandUuid: String,
|
||||||
|
@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
|
@Serializable
|
||||||
data class ApiErrorBody(
|
data class ApiErrorBody(
|
||||||
val detail: String? = null,
|
val detail: String? = null,
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class SacRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
val okhttp = OkHttpClient.Builder()
|
val okhttp = OkHttpClient.Builder()
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
|
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
|
||||||
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
|
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.kalinamall.seaca.push
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
|
||||||
|
/** True, пока хотя бы одна activity приложения видима (ProcessLifecycleOwner.onStart). */
|
||||||
|
object AppForegroundState {
|
||||||
|
@Volatile
|
||||||
|
private var inForeground = false
|
||||||
|
|
||||||
|
private lateinit var appContext: Context
|
||||||
|
|
||||||
|
fun bind(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(
|
||||||
|
object : DefaultLifecycleObserver {
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
inForeground = true
|
||||||
|
NotificationHelper.clearAll(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
|
inForeground = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isInForeground(): Boolean = inForeground
|
||||||
|
}
|
||||||
@@ -13,36 +13,47 @@ import ru.kalinamall.seaca.MainActivity
|
|||||||
import ru.kalinamall.seaca.R
|
import ru.kalinamall.seaca.R
|
||||||
|
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
const val CHANNEL_DEFAULT = "sac_default"
|
private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
|
||||||
const val CHANNEL_HIGH = "sac_high"
|
private const val CHANNEL_HIGH_SILENT = "sac_high_silent"
|
||||||
const val CHANNEL_CRITICAL = "sac_critical"
|
private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
|
||||||
const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
|
|
||||||
const val CHANNEL_HIGH_SILENT = "sac_high_silent"
|
private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical")
|
||||||
const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
|
|
||||||
|
|
||||||
private val soundChannelIds = listOf(CHANNEL_DEFAULT, CHANNEL_HIGH, CHANNEL_CRITICAL)
|
|
||||||
private var appliedSoundKey: String? = null
|
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()) {
|
fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val soundKey = soundUri.toString()
|
val soundKey = soundUri.toString()
|
||||||
val mgr = context.getSystemService(NotificationManager::class.java)
|
val mgr = context.getSystemService(NotificationManager::class.java)
|
||||||
if (soundKey != appliedSoundKey) {
|
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
|
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()
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
.build()
|
.build()
|
||||||
mgr.createNotificationChannel(
|
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(
|
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(
|
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_DEFAULT_SILENT, R.string.channel_default_silent))
|
||||||
mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent))
|
mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent))
|
||||||
@@ -72,6 +83,10 @@ object NotificationHelper {
|
|||||||
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
|
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) {
|
||||||
|
context.getSystemService(NotificationManager::class.java).cancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
fun show(
|
fun show(
|
||||||
context: Context,
|
context: Context,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -106,9 +121,9 @@ object NotificationHelper {
|
|||||||
.setPriority(
|
.setPriority(
|
||||||
when {
|
when {
|
||||||
silent -> NotificationCompat.PRIORITY_DEFAULT
|
silent -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT ->
|
channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT ->
|
||||||
NotificationCompat.PRIORITY_MAX
|
NotificationCompat.PRIORITY_MAX
|
||||||
channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT ->
|
channel == channelHigh || channel == CHANNEL_HIGH_SILENT ->
|
||||||
NotificationCompat.PRIORITY_HIGH
|
NotificationCompat.PRIORITY_HIGH
|
||||||
else -> NotificationCompat.PRIORITY_DEFAULT
|
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
},
|
},
|
||||||
@@ -132,9 +147,9 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return when (severity?.lowercase()) {
|
return when (severity?.lowercase()) {
|
||||||
"critical" -> CHANNEL_CRITICAL
|
"critical" -> channelCritical
|
||||||
"high" -> CHANNEL_HIGH
|
"high" -> channelHigh
|
||||||
else -> CHANNEL_DEFAULT
|
else -> channelDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,37 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.RingtoneManager
|
import android.media.RingtoneManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import ru.kalinamall.seaca.data.NotificationSoundSettings
|
import ru.kalinamall.seaca.data.NotificationSoundSettings
|
||||||
import ru.kalinamall.seaca.data.NotificationSoundSource
|
import ru.kalinamall.seaca.data.NotificationSoundSource
|
||||||
|
import ru.kalinamall.seaca.data.SessionStore
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
object NotificationSoundResolver {
|
object NotificationSoundResolver {
|
||||||
|
private const val CUSTOM_SOUND_FILE = "custom_notification_sound"
|
||||||
|
|
||||||
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
|
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
|
||||||
return when (settings.source) {
|
return when (settings.source) {
|
||||||
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
|
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
|
||||||
NotificationSoundSource.CUSTOM -> {
|
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 =
|
fun defaultNotificationUri(): Uri =
|
||||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
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 {
|
try {
|
||||||
context.contentResolver.takePersistableUriPermission(
|
context.contentResolver.takePersistableUriPermission(
|
||||||
uri,
|
uri,
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||||
)
|
)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
// Picker may not grant persistable permission for some URIs.
|
// Best-effort; internal copy is the reliable path.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
|||||||
private val repo by lazy { SacRepository(applicationContext) }
|
private val repo by lazy { SacRepository(applicationContext) }
|
||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
if (AppForegroundState.isInForeground()) return
|
||||||
val mode = runBlocking { repo.session.getNotificationMode() }
|
val mode = runBlocking { repo.session.getNotificationMode() }
|
||||||
when (mode) {
|
when (mode) {
|
||||||
NotificationMode.OFF -> return
|
NotificationMode.OFF -> return
|
||||||
@@ -30,7 +31,9 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
|||||||
val soundUri = if (silent) {
|
val soundUri = if (silent) {
|
||||||
NotificationSoundResolver.defaultNotificationUri()
|
NotificationSoundResolver.defaultNotificationUri()
|
||||||
} else {
|
} else {
|
||||||
val settings = runBlocking { repo.session.getNotificationSoundSettings() }
|
val settings = runBlocking {
|
||||||
|
NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session)
|
||||||
|
}
|
||||||
NotificationSoundResolver.resolve(this, settings)
|
NotificationSoundResolver.resolve(this, settings)
|
||||||
}
|
}
|
||||||
NotificationHelper.show(
|
NotificationHelper.show(
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package ru.kalinamall.seaca.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import ru.kalinamall.seaca.data.HostSessionItem
|
||||||
|
import ru.kalinamall.seaca.data.HostSessionTerminateRequest
|
||||||
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HostSessionsSection(
|
||||||
|
repo: SacRepository,
|
||||||
|
hostId: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var loading by remember(hostId) { mutableStateOf(false) }
|
||||||
|
var loaded by remember(hostId) { mutableStateOf(false) }
|
||||||
|
var sessions by remember(hostId) { mutableStateOf<List<HostSessionItem>>(emptyList()) }
|
||||||
|
var error by remember(hostId) { mutableStateOf<String?>(null) }
|
||||||
|
var message by remember(hostId) { mutableStateOf<String?>(null) }
|
||||||
|
var messageOk by remember(hostId) { mutableStateOf(true) }
|
||||||
|
var terminatingId by remember(hostId) { mutableStateOf<String?>(null) }
|
||||||
|
var confirmSession by remember(hostId) { mutableStateOf<HostSessionItem?>(null) }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
scope.launch {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
message = null
|
||||||
|
try {
|
||||||
|
val res = repo.withAuth { it.postHostSessionsList(hostId) }
|
||||||
|
if (!res.ok) {
|
||||||
|
error = res.message
|
||||||
|
sessions = emptyList()
|
||||||
|
} else {
|
||||||
|
sessions = res.sessions
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = if (e is HttpException) SacRepository.parseError(e) else e.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun terminate(sessionId: String) {
|
||||||
|
scope.launch {
|
||||||
|
terminatingId = sessionId
|
||||||
|
message = null
|
||||||
|
try {
|
||||||
|
val res = repo.withAuth {
|
||||||
|
it.postHostSessionTerminate(hostId, HostSessionTerminateRequest(sessionId))
|
||||||
|
}
|
||||||
|
messageOk = res.ok
|
||||||
|
message = res.message
|
||||||
|
if (res.ok) {
|
||||||
|
sessions = sessions.filter { it.sessionId != sessionId }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
messageOk = false
|
||||||
|
message = if (e is HttpException) SacRepository.parseError(e) else e.message
|
||||||
|
} finally {
|
||||||
|
terminatingId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSession?.let { session ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmSession = null },
|
||||||
|
title = { Text("Завершить сессию?") },
|
||||||
|
text = { Text("Завершить сессию ${session.sessionId} (${session.user})?") },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
val sid = session.sessionId
|
||||||
|
confirmSession = null
|
||||||
|
terminate(sid)
|
||||||
|
},
|
||||||
|
) { Text("Завершить") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmSession = null }) { Text("Отмена") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"Залогиненные пользователи",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Linux: loginctl, Windows: qwinsta. Нужны учётные данные admin в настройках SAC.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
error?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (loaded) {
|
||||||
|
if (sessions.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Активных сессий не найдено.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sessions.forEach { session ->
|
||||||
|
HostSessionRow(
|
||||||
|
session = session,
|
||||||
|
terminating = terminatingId == session.sessionId,
|
||||||
|
enabled = terminatingId == null && !loading,
|
||||||
|
onTerminate = { confirmSession = session },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = if (messageOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = !loading,
|
||||||
|
onClick = { load() },
|
||||||
|
) {
|
||||||
|
if (loading) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text("Загрузка…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(if (loaded) "Обновить" else "Показать пользователей")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HostSessionRow(
|
||||||
|
session: HostSessionItem,
|
||||||
|
terminating: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onTerminate: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(session.user, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
append("ID ${session.sessionId}")
|
||||||
|
session.tty?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||||
|
session.sessionName?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||||
|
session.state?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (terminating) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp))
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
enabled = enabled,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onClick = onTerminate,
|
||||||
|
) { Text("Завершить") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EventSessionTerminateButton(
|
||||||
|
enabled: Boolean,
|
||||||
|
loading: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = enabled && !loading,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(if (loading) "…" else "Завершить")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package ru.kalinamall.seaca.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val RdgAccessText = Color(0xFF7EC8E3)
|
||||||
|
private val RdgAccessBackground = Color(0x1A7EC8E3)
|
||||||
|
private val RdgAccessBorder = Color(0x667EC8E3)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RdgAccessBadge(
|
||||||
|
path: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.border(1.dp, RdgAccessBorder, RoundedCornerShape(4.dp)),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
color = RdgAccessBackground,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = path,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
color = RdgAccessText,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 0.3.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.rdgQwinstaEventId
|
||||||
|
|
||||||
|
class RdgQwinstaController(private val repo: SacRepository) {
|
||||||
|
var open by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var loading by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var error by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var meta by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var stdout by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var sessions by mutableStateOf<List<QwinstaSessionRow>>(emptyList())
|
||||||
|
private set
|
||||||
|
var logoffSessionId by mutableStateOf<Int?>(null)
|
||||||
|
private set
|
||||||
|
var targetEventId by mutableStateOf<Long?>(null)
|
||||||
|
private set
|
||||||
|
private var actorUser: String? = null
|
||||||
|
|
||||||
|
fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
|
||||||
|
val targetId = rdgQwinstaEventId(event) ?: return
|
||||||
|
targetEventId = targetId
|
||||||
|
actorUser = event.actorUser
|
||||||
|
open = true
|
||||||
|
runQwinsta(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
|
||||||
|
val targetId = rdgQwinstaEventId(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("Оборвать сессию", 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) "…" else "Оборвать сессию")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package ru.kalinamall.seaca.ui.screens
|
package ru.kalinamall.seaca.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -52,7 +52,7 @@ fun ConnectScreen(
|
|||||||
value = serverUrl,
|
value = serverUrl,
|
||||||
onValueChange = { serverUrl = it },
|
onValueChange = { serverUrl = it },
|
||||||
label = { Text("URL сервера") },
|
label = { Text("URL сервера") },
|
||||||
placeholder = { Text("https://sac-api.kalinamall.ru") },
|
placeholder = { Text("https://host.example") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -45,6 +49,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import retrofit2.HttpException
|
||||||
import ru.kalinamall.seaca.R
|
import ru.kalinamall.seaca.R
|
||||||
import ru.kalinamall.seaca.data.EventSummary
|
import ru.kalinamall.seaca.data.EventSummary
|
||||||
import ru.kalinamall.seaca.data.EventDetail
|
import ru.kalinamall.seaca.data.EventDetail
|
||||||
@@ -55,7 +60,17 @@ import ru.kalinamall.seaca.data.NotificationMode
|
|||||||
import ru.kalinamall.seaca.data.NotificationSoundSettings
|
import ru.kalinamall.seaca.data.NotificationSoundSettings
|
||||||
import ru.kalinamall.seaca.data.NotificationSoundSource
|
import ru.kalinamall.seaca.data.NotificationSoundSource
|
||||||
import ru.kalinamall.seaca.data.ProblemDetail
|
import ru.kalinamall.seaca.data.ProblemDetail
|
||||||
|
import ru.kalinamall.seaca.data.LogoffActionRequest
|
||||||
import ru.kalinamall.seaca.data.SacRepository
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
||||||
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
||||||
|
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
|
||||||
|
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
|
||||||
|
import ru.kalinamall.seaca.ui.components.HostSessionsSection
|
||||||
|
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
||||||
|
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
|
||||||
|
import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate
|
||||||
|
import ru.kalinamall.seaca.ui.util.sessionTerminateLabel
|
||||||
import ru.kalinamall.seaca.push.NotificationHelper
|
import ru.kalinamall.seaca.push.NotificationHelper
|
||||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||||
import ru.kalinamall.seaca.ui.RingtonePicker
|
import ru.kalinamall.seaca.ui.RingtonePicker
|
||||||
@@ -78,6 +93,11 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
var data by remember { mutableStateOf<EventDetail?>(null) }
|
var data by remember { mutableStateOf<EventDetail?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val qwinsta = rememberRdgQwinstaController(repo)
|
||||||
|
var terminateLoading by remember { mutableStateOf(false) }
|
||||||
|
var terminateConfirm by remember { mutableStateOf(false) }
|
||||||
|
var terminateMessage by remember { mutableStateOf<String?>(null) }
|
||||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
initialValue = DateTimeDisplayMode.SLASH,
|
||||||
)
|
)
|
||||||
@@ -95,6 +115,31 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
|
|
||||||
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
||||||
val d = data ?: return@DetailFrame
|
val d = data ?: return@DetailFrame
|
||||||
|
if (!d.rdgAccessPath.isNullOrBlank()) {
|
||||||
|
Field("Путь RDS", d.rdgAccessPath)
|
||||||
|
}
|
||||||
|
if (hasRdgFlapUi(d)) {
|
||||||
|
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
|
||||||
|
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
|
||||||
|
}
|
||||||
|
rdgQwinstaEventId(d)?.let {
|
||||||
|
RdgQwinstaButton(
|
||||||
|
controller = qwinsta,
|
||||||
|
onClick = { qwinsta.openFor(d, scope) },
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (eventSupportsSessionTerminate(d)) {
|
||||||
|
EventSessionTerminateButton(
|
||||||
|
enabled = !terminateLoading,
|
||||||
|
loading = terminateLoading,
|
||||||
|
onClick = { terminateConfirm = true },
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
terminateMessage?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
Field("Severity", d.severity)
|
Field("Severity", d.severity)
|
||||||
Field(
|
Field(
|
||||||
"Тип",
|
"Тип",
|
||||||
@@ -110,6 +155,55 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
EventDetailsSection(d.type, d.details, d.payload)
|
EventDetailsSection(d.type, d.details, d.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (terminateConfirm) {
|
||||||
|
val d = data
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { terminateConfirm = false },
|
||||||
|
title = { Text("Завершить сессию?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (d != null) {
|
||||||
|
"Завершить сессию пользователя ${sessionTerminateLabel(d)}?"
|
||||||
|
} else {
|
||||||
|
"Завершить сессию пользователя?"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
terminateConfirm = false
|
||||||
|
scope.launch {
|
||||||
|
terminateLoading = true
|
||||||
|
terminateMessage = null
|
||||||
|
try {
|
||||||
|
val res = repo.withAuth { it.postEventTerminateSession(id) }
|
||||||
|
if (!res.ok) {
|
||||||
|
terminateMessage = res.message
|
||||||
|
} else {
|
||||||
|
data = data?.copy(sessionTerminated = true)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
terminateMessage = if (e is HttpException) SacRepository.parseError(e) else e.message
|
||||||
|
} finally {
|
||||||
|
terminateLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text("Завершить") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { terminateConfirm = false }) { Text("Отмена") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RdgQwinstaDialog(qwinsta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -292,10 +386,10 @@ fun HostDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (HostDetailTab.entries[selectedTab]) {
|
when (HostDetailTab.entries[selectedTab]) {
|
||||||
HostDetailTab.Info -> HostInfoContent(host, dateMode)
|
HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
|
||||||
HostDetailTab.Events -> HostEventsContent(
|
HostDetailTab.Events -> HostEventsContent(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
hostname = host.hostname,
|
hostId = host.id,
|
||||||
onOpenEvent = onOpenEvent,
|
onOpenEvent = onOpenEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -304,7 +398,7 @@ fun HostDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -317,6 +411,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
|||||||
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
|
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
|
||||||
Field("Агент", host.agentStatus)
|
Field("Агент", host.agentStatus)
|
||||||
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
|
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
|
||||||
|
HostSessionsSection(repo = repo, hostId = host.id)
|
||||||
host.inventory?.let { inv ->
|
host.inventory?.let { inv ->
|
||||||
val lines = inventoryFieldLines(inv)
|
val lines = inventoryFieldLines(inv)
|
||||||
if (lines.isEmpty()) {
|
if (lines.isEmpty()) {
|
||||||
@@ -333,7 +428,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun HostEventsContent(
|
private fun HostEventsContent(
|
||||||
repo: SacRepository,
|
repo: SacRepository,
|
||||||
hostname: String,
|
hostId: Long,
|
||||||
onOpenEvent: (Long) -> Unit,
|
onOpenEvent: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
var page by remember { mutableIntStateOf(1) }
|
var page by remember { mutableIntStateOf(1) }
|
||||||
@@ -353,7 +448,12 @@ private fun HostEventsContent(
|
|||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
val res = repo.withAuth {
|
val res = repo.withAuth {
|
||||||
it.listEvents(page = page, pageSize = 30, hostname = hostname)
|
it.listEvents(
|
||||||
|
page = page,
|
||||||
|
pageSize = 30,
|
||||||
|
hostId = hostId,
|
||||||
|
includeHidden = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
items = res.items
|
items = res.items
|
||||||
total = res.total
|
total = res.total
|
||||||
@@ -366,7 +466,7 @@ private fun HostEventsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(page, hostname) { load() }
|
LaunchedEffect(page, hostId) { load() }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -448,9 +548,12 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(soundSettings) {
|
LaunchedEffect(soundSettings, notificationMode) {
|
||||||
if (notificationMode == NotificationMode.PUSH_SOUND) {
|
if (notificationMode == NotificationMode.PUSH_SOUND) {
|
||||||
applySoundChannels(soundSettings)
|
val normalized = repo.session.let {
|
||||||
|
NotificationSoundResolver.normalizeStoredSettings(context, it)
|
||||||
|
}
|
||||||
|
applySoundChannels(normalized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,11 +565,15 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
|||||||
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
|
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
|
||||||
try {
|
try {
|
||||||
RingtonePicker.launch(activity, currentUri) { uri ->
|
RingtonePicker.launch(activity, currentUri) { uri ->
|
||||||
NotificationSoundResolver.persistCustomUri(context, uri)
|
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
|
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
|
||||||
} else {
|
} 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())
|
applySoundChannels(repo.session.getNotificationSoundSettings())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MenuAnchorType
|
import androidx.compose.material3.MenuAnchorType
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
@@ -38,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
import ru.kalinamall.seaca.data.DateTimeDisplayMode
|
import ru.kalinamall.seaca.data.DateTimeDisplayMode
|
||||||
import ru.kalinamall.seaca.data.DashboardSummary
|
import ru.kalinamall.seaca.data.DashboardSummary
|
||||||
import ru.kalinamall.seaca.data.EventListResponse
|
import ru.kalinamall.seaca.data.EventListResponse
|
||||||
@@ -46,8 +52,18 @@ import ru.kalinamall.seaca.data.HostSummary
|
|||||||
import ru.kalinamall.seaca.data.ProblemSummary
|
import ru.kalinamall.seaca.data.ProblemSummary
|
||||||
import ru.kalinamall.seaca.data.SacRepository
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
import ru.kalinamall.seaca.ui.components.BackArrowButton
|
import ru.kalinamall.seaca.ui.components.BackArrowButton
|
||||||
|
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
|
||||||
|
import ru.kalinamall.seaca.ui.components.RdgAccessBadge
|
||||||
|
import ru.kalinamall.seaca.ui.components.RdgFlapBadge
|
||||||
|
import ru.kalinamall.seaca.ui.components.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.components.ForwardArrowButton
|
||||||
import ru.kalinamall.seaca.ui.util.SacDateTimes
|
import ru.kalinamall.seaca.ui.util.SacDateTimes
|
||||||
|
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
||||||
|
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
|
||||||
|
import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate
|
||||||
|
import ru.kalinamall.seaca.ui.util.sessionTerminateLabel
|
||||||
|
|
||||||
private val REPORT_TYPE_OPTIONS = listOf(
|
private val REPORT_TYPE_OPTIONS = listOf(
|
||||||
"report.daily.ssh" to "Отчёты ssh клиентов",
|
"report.daily.ssh" to "Отчёты ssh клиентов",
|
||||||
@@ -62,6 +78,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
var loading by remember { mutableStateOf(false) }
|
var loading by remember { mutableStateOf(false) }
|
||||||
var refreshing by remember { mutableStateOf(false) }
|
var refreshing by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val qwinsta = rememberRdgQwinstaController(repo)
|
||||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
initialValue = DateTimeDisplayMode.SLASH,
|
||||||
)
|
)
|
||||||
@@ -121,12 +138,25 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
dashboardRecentEventLine(e),
|
if (!e.rdgAccessPath.isNullOrBlank()) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
RdgAccessBadge(path = e.rdgAccessPath)
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
}
|
||||||
modifier = Modifier.weight(1f),
|
if (hasRdgFlapUi(e)) {
|
||||||
)
|
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
dashboardRecentEventLine(e),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
rdgQwinstaEventId(e)?.let {
|
||||||
|
RdgQwinstaButton(
|
||||||
|
controller = qwinsta,
|
||||||
|
onClick = { qwinsta.openFor(e, scope) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
SacDateTimes.format(e.occurredAt, dateMode),
|
SacDateTimes.format(e.occurredAt, dateMode),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -140,6 +170,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RdgQwinstaDialog(qwinsta)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dashboardRecentEventLine(event: EventSummary): String {
|
private fun dashboardRecentEventLine(event: EventSummary): String {
|
||||||
@@ -171,10 +202,55 @@ private fun StatCard(label: String, value: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
||||||
var hostname by remember { mutableStateOf("") }
|
var hostname by remember { mutableStateOf("") }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val qwinsta = rememberRdgQwinstaController(repo)
|
||||||
|
var terminateLoadingId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
var terminateConfirm by remember { mutableStateOf<EventSummary?>(null) }
|
||||||
|
var terminateError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var locallyTerminatedIds by remember { mutableStateOf(setOf<Long>()) }
|
||||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
initialValue = DateTimeDisplayMode.SLASH,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
terminateConfirm?.let { event ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { terminateConfirm = null },
|
||||||
|
title = { Text("Завершить сессию?") },
|
||||||
|
text = { Text("Завершить сессию пользователя ${sessionTerminateLabel(event)}?") },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
val target = event
|
||||||
|
terminateConfirm = null
|
||||||
|
scope.launch {
|
||||||
|
terminateLoadingId = target.id
|
||||||
|
terminateError = null
|
||||||
|
try {
|
||||||
|
val res = repo.withAuth { it.postEventTerminateSession(target.id) }
|
||||||
|
if (!res.ok) {
|
||||||
|
terminateError = res.message
|
||||||
|
} else {
|
||||||
|
locallyTerminatedIds = locallyTerminatedIds + target.id
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
terminateError = if (e is HttpException) SacRepository.parseError(e) else e.message
|
||||||
|
} finally {
|
||||||
|
terminateLoadingId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text("Завершить") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { terminateConfirm = null }) { Text("Отмена") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
PagedListScreen(
|
PagedListScreen(
|
||||||
title = "События",
|
title = "События",
|
||||||
loadPage = { page, severity, host ->
|
loadPage = { page, severity, host ->
|
||||||
@@ -191,10 +267,24 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
|||||||
itemKey = EventSummary::id,
|
itemKey = EventSummary::id,
|
||||||
itemLabel = { "${it.severity} · ${it.title}" },
|
itemLabel = { "${it.severity} · ${it.title}" },
|
||||||
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
||||||
|
itemRdgAccessPath = { it.rdgAccessPath },
|
||||||
|
itemRdgFlap = { hasRdgFlapUi(it) },
|
||||||
|
itemRdgFlapPairId = { it.rdgFlapPairEventId },
|
||||||
|
itemQwinsta = { rdgQwinstaEventId(it) != null },
|
||||||
|
onQwinsta = { qwinsta.openFor(it, scope) },
|
||||||
|
qwinstaLoading = qwinsta.loading,
|
||||||
|
itemTerminate = {
|
||||||
|
eventSupportsSessionTerminate(it) && it.id !in locallyTerminatedIds
|
||||||
|
},
|
||||||
|
onTerminate = { terminateConfirm = it },
|
||||||
|
terminateLoadingId = terminateLoadingId,
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
severities = listOf(null, "info", "warning", "high", "critical"),
|
severities = listOf(null, "info", "warning", "high", "critical"),
|
||||||
hostnameFilter = hostname,
|
hostnameFilter = hostname,
|
||||||
headerContent = {
|
headerContent = {
|
||||||
|
terminateError?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = hostname,
|
value = hostname,
|
||||||
onValueChange = { hostname = it },
|
onValueChange = { hostname = it },
|
||||||
@@ -205,6 +295,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
RdgQwinstaDialog(qwinsta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -327,6 +418,15 @@ private fun <T> PagedListScreen(
|
|||||||
itemSub: (T) -> String,
|
itemSub: (T) -> String,
|
||||||
onOpen: (Long) -> Unit,
|
onOpen: (Long) -> Unit,
|
||||||
severities: List<String?>,
|
severities: List<String?>,
|
||||||
|
itemRdgAccessPath: ((T) -> String?)? = null,
|
||||||
|
itemRdgFlap: ((T) -> Boolean)? = null,
|
||||||
|
itemRdgFlapPairId: ((T) -> Long?)? = null,
|
||||||
|
itemQwinsta: ((T) -> Boolean)? = null,
|
||||||
|
onQwinsta: ((T) -> Unit)? = null,
|
||||||
|
qwinstaLoading: Boolean = false,
|
||||||
|
itemTerminate: ((T) -> Boolean)? = null,
|
||||||
|
onTerminate: ((T) -> Unit)? = null,
|
||||||
|
terminateLoadingId: Long? = null,
|
||||||
filterLabel: String = "Severity",
|
filterLabel: String = "Severity",
|
||||||
hostnameFilter: String = "",
|
hostnameFilter: String = "",
|
||||||
headerContent: @Composable () -> Unit = {},
|
headerContent: @Composable () -> Unit = {},
|
||||||
@@ -385,7 +485,20 @@ private fun <T> PagedListScreen(
|
|||||||
) {
|
) {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(items, key = { itemKey(it) }) { item ->
|
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)) },
|
||||||
|
rdgAccessPath = itemRdgAccessPath?.invoke(item),
|
||||||
|
rdgFlap = itemRdgFlap?.invoke(item) == true,
|
||||||
|
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
|
||||||
|
showQwinsta = itemQwinsta?.invoke(item) == true,
|
||||||
|
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
|
||||||
|
qwinstaLoading = qwinstaLoading,
|
||||||
|
showTerminate = itemTerminate?.invoke(item) == true,
|
||||||
|
onTerminateClick = onTerminate?.let { handler -> { handler(item) } },
|
||||||
|
terminateLoading = terminateLoadingId == itemKey(item),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,20 +507,58 @@ private fun <T> PagedListScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) {
|
private fun ListRow(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
rdgAccessPath: String? = null,
|
||||||
|
rdgFlap: Boolean = false,
|
||||||
|
rdgFlapPairId: Long? = null,
|
||||||
|
showQwinsta: Boolean = false,
|
||||||
|
onQwinstaClick: (() -> Unit)? = null,
|
||||||
|
qwinstaLoading: Boolean = false,
|
||||||
|
showTerminate: Boolean = false,
|
||||||
|
onTerminateClick: (() -> Unit)? = null,
|
||||||
|
terminateLoading: Boolean = false,
|
||||||
|
) {
|
||||||
Card(
|
Card(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp),
|
||||||
.clickable(onClick = onClick),
|
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
|
Column(
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (!rdgAccessPath.isNullOrBlank()) {
|
||||||
|
RdgAccessBadge(path = rdgAccessPath)
|
||||||
|
}
|
||||||
|
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) "…" else "Оборвать сессию") }
|
||||||
|
}
|
||||||
|
if (showTerminate && onTerminateClick != null) {
|
||||||
|
EventSessionTerminateButton(
|
||||||
|
enabled = !terminateLoading,
|
||||||
|
loading = terminateLoading,
|
||||||
|
onClick = onTerminateClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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 hasRdgFlapUi(event: EventDetail): Boolean =
|
||||||
|
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
||||||
|
|
||||||
|
fun hasRdgAccessPath(event: EventSummary): Boolean =
|
||||||
|
!event.rdgAccessPath.isNullOrBlank()
|
||||||
|
|
||||||
|
fun hasRdgAccessPath(event: EventDetail): Boolean =
|
||||||
|
!event.rdgAccessPath.isNullOrBlank()
|
||||||
|
|
||||||
|
/** Как SAC rdgQwinstaEventId: кнопка только при rdg_qwinsta_enabled от API. */
|
||||||
|
fun rdgQwinstaEventId(event: EventSummary): Long? {
|
||||||
|
if (!event.rdgQwinstaEnabled) return null
|
||||||
|
return event.rdgFlapQwinstaEventId ?: event.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rdgQwinstaEventId(event: EventDetail): Long? {
|
||||||
|
if (!event.rdgQwinstaEnabled) return null
|
||||||
|
return event.rdgFlapQwinstaEventId ?: event.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use rdgQwinstaEventId */
|
||||||
|
fun rdgFlapQwinstaEventId(event: EventSummary): Long? = rdgQwinstaEventId(event)
|
||||||
|
|
||||||
|
/** @deprecated use rdgQwinstaEventId */
|
||||||
|
fun rdgFlapQwinstaEventId(event: EventDetail): Long? = rdgQwinstaEventId(event)
|
||||||
|
|
||||||
|
fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessionRow> {
|
||||||
|
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
val rows = mutableListOf<QwinstaSessionRow>()
|
||||||
|
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() }
|
||||||
|
?: "Команда завершилась с ошибкой"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ru.kalinamall.seaca.ui.util
|
||||||
|
|
||||||
|
import ru.kalinamall.seaca.data.EventDetail
|
||||||
|
import ru.kalinamall.seaca.data.EventSummary
|
||||||
|
|
||||||
|
private val LINUX_SESSION_EVENT_TYPES = setOf(
|
||||||
|
"session.logind.new",
|
||||||
|
"ssh.login.success",
|
||||||
|
"privilege.sudo.command",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val WINDOWS_SESSION_EVENT_TYPES = setOf("rdp.login.success")
|
||||||
|
|
||||||
|
fun eventSupportsSessionTerminate(event: EventSummary): Boolean =
|
||||||
|
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
|
||||||
|
!event.sessionTerminated
|
||||||
|
|
||||||
|
fun eventSupportsSessionTerminate(event: EventDetail): Boolean =
|
||||||
|
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
|
||||||
|
!event.sessionTerminated
|
||||||
|
|
||||||
|
fun sessionTerminateLabel(event: EventSummary): String =
|
||||||
|
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
|
||||||
|
|
||||||
|
fun sessionTerminateLabel(event: EventDetail): String =
|
||||||
|
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<files-path name="notification_sounds" path="." />
|
||||||
|
</paths>
|
||||||
+10
-5
@@ -1,4 +1,4 @@
|
|||||||
# Roadmap — Seaca
|
# Roadmap — Seaca
|
||||||
|
|
||||||
Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в [security-alert-center](https://git.kalinamall.ru/PapaTramp/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**.
|
||||||
|
|
||||||
@@ -55,23 +55,28 @@
|
|||||||
|
|
||||||
## v0.4 — Push (FCM) ✅ (частично)
|
## v0.4 — Push (FCM) ✅ (частично)
|
||||||
|
|
||||||
- [x] Firebase-проект, `google-services.json` (не в git; пример `google-services.json.example`)
|
- [x] Firebase-проект, конфиг в app/ (не в git; шаблон `*.example`)
|
||||||
- [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm`
|
- [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm`
|
||||||
- [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
|
- [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
|
||||||
- [x] Notification channels по severity (critical / high / default)
|
- [x] Notification channels по severity (critical / high / default)
|
||||||
- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+) — **после первого APK**
|
- [x] RDG flap: qwinsta/logoff через SAC API (карточка события)
|
||||||
- [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
|
- [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
|
||||||
|
|
||||||
**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
|
**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.5 — Полировка (позже)
|
## v0.5 — Полировка ✅ (основное)
|
||||||
|
|
||||||
- [x] Refresh token rotation (`POST /api/v1/mobile/auth/refresh`) — уже в v0.1
|
- [x] Refresh token rotation (`POST /api/v1/mobile/auth/refresh`) — уже в v0.1
|
||||||
|
- [x] Биометрия при **запуске** и при возврате в приложение
|
||||||
|
- [x] Pull-to-refresh на обзоре и в списках
|
||||||
|
- [x] Формат даты/времени (несколько пресетов + системный локаль)
|
||||||
|
- [x] Режимы push на устройстве: звук / без звука / выкл.
|
||||||
|
- [x] Звук push: системный или пользовательский (ringtone picker)
|
||||||
|
- [x] Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки, «Выйти»
|
||||||
- [ ] Виджет: число open problems
|
- [ ] Виджет: число open problems
|
||||||
- [ ] Offline-кэш последних N событий (Room)
|
- [ ] Offline-кэш последних N событий (Room)
|
||||||
- [x] Экран «Моё устройство»: имя, сервер, версия, «Выйти» (базовый)
|
|
||||||
- [ ] Минимальная версия приложения (проверка с сервера)
|
- [ ] Минимальная версия приложения (проверка с сервера)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+24
-22
@@ -1,37 +1,39 @@
|
|||||||
# Seaca — руководство оператора
|
# Seaca — оператор
|
||||||
|
|
||||||
## Первая настройка
|
## Подключение
|
||||||
|
|
||||||
1. Получите от администратора:
|
URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату.
|
||||||
- адрес сервера (**`https://sac-api.kalinamall.ru`** — мобильный API; веб для админов — `https://sac.kalinamall.ru`);
|
|
||||||
- код регистрации `sacmob_…`;
|
|
||||||
- при необходимости — логин и пароль SAC.
|
|
||||||
2. Установите APK Seaca.
|
|
||||||
3. На экране подключения введите данные → **Подключиться**.
|
|
||||||
4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
|
|
||||||
|
|
||||||
После успеха код больше не нужен — приложение запоминает сессию.
|
|
||||||
|
|
||||||
## Разделы
|
## Разделы
|
||||||
|
|
||||||
| Раздел | Содержание |
|
| Раздел | Содержание |
|
||||||
|--------|------------|
|
|--------|------------|
|
||||||
| Обзор | Сводка за 24 ч, открытые проблемы |
|
| Обзор | Сводка 24 ч; pull-to-refresh |
|
||||||
| События | Лента с фильтром по severity |
|
| События | Лента; **RDG flap** в заголовке |
|
||||||
| Проблемы | Инциденты; для monitor/admin — подтвердить / закрыть |
|
| Проблемы | Ack / Resolve (monitor+) |
|
||||||
| Хосты | Агенты ssh-monitor / RDP-login-monitor |
|
| Хосты | Агенты, heartbeat |
|
||||||
| Отчёты | Суточные отчёты SSH и RDP |
|
| Отчёты | `report.daily.ssh` / `report.daily.rdp` |
|
||||||
|
| Моё устройство | Push, звук, дата, выход |
|
||||||
|
|
||||||
## Push-уведомления
|
## RDG flap — qwinsta / logoff
|
||||||
|
|
||||||
Появятся после настройки FCM администратором. Нажатие на уведомление открывает карточку события или проблемы.
|
Пара RD Gateway **302→303** за несколько секунд:
|
||||||
|
|
||||||
|
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.
|
Новый код или снятие отзыва устройства в веб-SAC.
|
||||||
|
|
||||||
Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC).
|
|
||||||
|
|||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Перед push на github: нет internal URL и файлов других зеркал.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
PATTERNS=(
|
||||||
|
'git\.kalinamall\.ru'
|
||||||
|
'git\.papatramp\.ru'
|
||||||
|
'papatramp\.lan'
|
||||||
|
'192\.168\.'
|
||||||
|
'kalinamall/main'
|
||||||
|
'push.*kalinamall'
|
||||||
|
)
|
||||||
|
|
||||||
|
scan_files() {
|
||||||
|
local hits=0
|
||||||
|
local files
|
||||||
|
files=$(git ls-files)
|
||||||
|
while read -r f; do
|
||||||
|
[[ -z $f ]] && continue
|
||||||
|
[[ -f $f ]] || continue
|
||||||
|
case $f in
|
||||||
|
scripts/check-github-clean.sh | scripts/push-remotes.sh) continue ;;
|
||||||
|
esac
|
||||||
|
for pat in "${PATTERNS[@]}"; do
|
||||||
|
if grep -qE "$pat" "$f" 2>/dev/null; then
|
||||||
|
echo " INTERNAL? $f (pattern: $pat)"
|
||||||
|
hits=$((hits + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done <<<"$files"
|
||||||
|
return $hits
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[check-github-clean] сканирование tracked-файлов"
|
||||||
|
n=0
|
||||||
|
scan_files || n=$?
|
||||||
|
if [[ $n -gt 0 ]]; then
|
||||||
|
echo "[check-github-clean] найдено: $n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for f in scripts/readme/kalinamall.md scripts/readme/home.md; do
|
||||||
|
if [[ -f $f ]]; then
|
||||||
|
echo " FORBIDDEN FILE: $f"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[check-github-clean] OK"
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Синхронизация main на kalinamall / home / github: свой README и только свои readme-шаблоны.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
CHECK_SECRETS="${CHECK_SECRETS:-$HOME/Documents/Cursor/Projects/Answer.and.other.shit/guides/init.new.comps/scripts/check-no-secrets.sh}"
|
||||||
|
CHECK_GITHUB="${CHECK_GITHUB:-$ROOT/scripts/check-github-clean.sh}"
|
||||||
|
|
||||||
|
sync_remote() {
|
||||||
|
local remote=$1
|
||||||
|
local readme_template=$2
|
||||||
|
local branch="sync-${remote}"
|
||||||
|
shift 2
|
||||||
|
local -a remove=( "$@" )
|
||||||
|
|
||||||
|
git fetch "$remote"
|
||||||
|
git checkout -B "$branch" "${remote}/main"
|
||||||
|
git checkout main -- .
|
||||||
|
cp "$readme_template" README.md
|
||||||
|
|
||||||
|
for f in "${remove[@]}"; do
|
||||||
|
if [[ -e $f ]]; then
|
||||||
|
rm -rf "$f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -rf .cursor .cursorignore
|
||||||
|
|
||||||
|
git add -u .
|
||||||
|
git add README.md
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "[push-remotes] $remote: без изменений"
|
||||||
|
else
|
||||||
|
git commit -m "chore: sync $remote (README и файлы зеркала)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $remote == github ]]; then
|
||||||
|
if [[ -f $CHECK_GITHUB ]]; then
|
||||||
|
bash "$CHECK_GITHUB"
|
||||||
|
fi
|
||||||
|
if [[ -f $CHECK_SECRETS ]]; then
|
||||||
|
bash "$CHECK_SECRETS" github
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
git push "$remote" "${branch}:main"
|
||||||
|
git checkout main
|
||||||
|
git branch -D "$branch"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[push-remotes] kalinamall"
|
||||||
|
sync_remote kalinamall "$ROOT/scripts/readme/kalinamall.md" \
|
||||||
|
scripts/readme/github.md \
|
||||||
|
scripts/readme/home.md
|
||||||
|
|
||||||
|
echo "[push-remotes] home"
|
||||||
|
sync_remote home "$ROOT/scripts/readme/home.md" \
|
||||||
|
scripts/readme/github.md \
|
||||||
|
scripts/readme/kalinamall.md \
|
||||||
|
scripts/push-remotes.sh \
|
||||||
|
scripts/check-github-clean.sh
|
||||||
|
|
||||||
|
echo "[push-remotes] github"
|
||||||
|
sync_remote github "$ROOT/scripts/readme/github.md" \
|
||||||
|
scripts/readme/kalinamall.md \
|
||||||
|
scripts/readme/home.md \
|
||||||
|
scripts/push-remotes.sh \
|
||||||
|
scripts/check-github-clean.sh
|
||||||
|
|
||||||
|
cp "$ROOT/scripts/readme/kalinamall.md" README.md
|
||||||
|
git add README.md
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "docs: README kalinamall (рабочая копия main)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[push-remotes] готово."
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## Стек
|
||||||
|
|
||||||
|
| Слой | Технология |
|
||||||
|
|------|------------|
|
||||||
|
| 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 (уточняется владельцем проекта).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Seaca
|
||||||
|
|
||||||
|
Android-клиент [Security Alert Center (SAC)](https://github.com/PTah/security-alert-center).
|
||||||
|
|
||||||
|
**Версия:** `0.5.15` (versionCode 29)
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
|
- Ack / Resolve проблем (monitor+)
|
||||||
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
|
- Биометрия при запуске и возврате в приложение
|
||||||
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
|
## Нет в приложении (только веб-SAC)
|
||||||
|
|
||||||
|
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|
||||||
|
|
||||||
|
## Привязка
|
||||||
|
|
||||||
|
1. Админ: мобильные устройства + код `sacmob_…`
|
||||||
|
2. URL API SAC + код + логин/пароль в Seaca
|
||||||
|
3. FCM-токен регистрируется автоматически
|
||||||
|
|
||||||
|
[docs/operator-guide.md](docs/operator-guide.md)
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
|
||||||
|
## Related repositories
|
||||||
|
|
||||||
|
| Repository | URL |
|
||||||
|
|------------|-----|
|
||||||
|
| seaca | https://github.com/PTah/seaca |
|
||||||
|
| security-alert-center | https://github.com/PTah/security-alert-center |
|
||||||
|
| ssh-monitor | https://github.com/PTah/ssh-monitor |
|
||||||
|
| RDP-login-monitor | https://github.com/PTah/RDP-login-monitor |
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Seaca
|
||||||
|
|
||||||
|
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
|
**Версия:** `0.5.15` (versionCode 29)
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
|
- Ack / Resolve проблем (monitor+)
|
||||||
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
|
- Биометрия при запуске и возврате в приложение
|
||||||
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
|
## Нет в приложении (только веб-SAC)
|
||||||
|
|
||||||
|
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|
||||||
|
|
||||||
|
## Привязка
|
||||||
|
|
||||||
|
1. Админ: мобильные устройства + код `sacmob_…`
|
||||||
|
2. URL API SAC + код + логин/пароль в Seaca
|
||||||
|
3. FCM-токен регистрируется автоматически
|
||||||
|
|
||||||
|
[docs/operator-guide.md](docs/operator-guide.md)
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
|
||||||
|
## Репозитории
|
||||||
|
|
||||||
|
| Репозиторий | URL |
|
||||||
|
|-------------|-----|
|
||||||
|
| seaca | https://git.papatramp.ru/PapaTramp/seaca |
|
||||||
|
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
|
||||||
|
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
|
||||||
|
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/rdp-login-monitor |
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Seaca
|
||||||
|
|
||||||
|
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
|
**Версия:** `0.5.15` (versionCode 29)
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||||
|
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
|
||||||
|
- Ack / Resolve проблем (monitor+)
|
||||||
|
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
|
||||||
|
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
|
||||||
|
- Биометрия при запуске и возврате в приложение
|
||||||
|
- Формат даты, экран «Моё устройство»
|
||||||
|
|
||||||
|
## Нет в приложении (только веб-SAC)
|
||||||
|
|
||||||
|
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|
||||||
|
|
||||||
|
## Привязка
|
||||||
|
|
||||||
|
1. Админ: мобильные устройства + код `sacmob_…`
|
||||||
|
2. URL API SAC + код + логин/пароль в Seaca
|
||||||
|
3. FCM-токен регистрируется автоматически
|
||||||
|
|
||||||
|
[docs/operator-guide.md](docs/operator-guide.md)
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
|
||||||
|
## Репозитории
|
||||||
|
|
||||||
|
| Репозиторий | URL |
|
||||||
|
|-------------|-----|
|
||||||
|
| seaca | https://git.kalinamall.ru/PapaTramp/seaca |
|
||||||
|
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
|
||||||
|
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
|
||||||
|
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/rdp-login-monitor |
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Скопируйте в secrets.properties в корне репозитория (рядом с gradlew.bat).
|
||||||
|
# Пароль — только ASCII (латиница, цифры, символы). Store и key — один пароль.
|
||||||
|
# seaca-release.jks — тот же файл на рабочем и домашнем ПК (не в git).
|
||||||
|
|
||||||
|
SEACA_STORE_FILE=seaca-release.jks
|
||||||
|
SEACA_STORE_PASSWORD=change-me-ascii-only
|
||||||
|
SEACA_KEY_ALIAS=seaca
|
||||||
|
SEACA_KEY_PASSWORD=change-me-ascii-only
|
||||||
Reference in New Issue
Block a user