merge: sync main with kalinamall (0.5.11)

This commit is contained in:
PTah
2026-06-21 19:43:30 +10:00
21 changed files with 767 additions and 315 deletions
+1 -1
View File
@@ -28,6 +28,6 @@ app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt
.env .env
.env.* .env.*
# Cursor (не в git — см. repo-policy) # Cursor (не в git)
.cursor/ .cursor/
.cursorignore .cursorignore
+22 -66
View File
@@ -1,87 +1,43 @@
# Seaca # Seaca
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android. Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»). **Версия:** `0.5.11` (versionCode 25)
## Связанные репозитории ## Возможности
| Репозиторий | Роль | - Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|-------------|------| - Push (FCM), режимы звука, выбор ringtone
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | - Ack / Resolve проблем (monitor+)
| ssh-monitor | Агент Linux | - **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
| RDP-login-monitor | Агент Windows | - Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
| Репозиторий | URL | ## Нет в приложении (только веб-SAC)
|-------------|-----|
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/RDP-login-monitor |
## Стек
| Слой | Технология | Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|------|------------|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Сеть | Retrofit / OkHttp, Kotlin Serialization |
| Хранение | DataStore + Android Keystore (токены, URL сервера) |
| Push | **Firebase Cloud Messaging (FCM)** |
| Минимум SDK | Android 8.0 (API 26) |
## Что есть в приложении ## Привязка
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями) 1. Админ: мобильные устройства + код `sacmob_…`
- Pull-to-refresh на обзоре и в списках 2. URL API SAC + код + логин/пароль в Seaca
- Фильтры событий и проблем (severity, status) 3. FCM-токен регистрируется автоматически
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
- Выбор звука уведомления: системный или свой (ringtone picker)
- Биометрическая блокировка при **запуске** и при возврате в приложение
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб) [docs/operator-guide.md](docs/operator-guide.md) · SAC [docs/seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md)
- Управление пользователями SAC
- Настройки каналов (Telegram, SMTP, webhook), severity, policy
- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
## Привязка телефона к серверу
1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии).
4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
## Статус
**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты)
## Документация
| Документ | Назначение |
|----------|------------|
| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
## Сборка ## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`). JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
```bash ```bash
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
./gradlew assembleDebug ./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
``` ```
APK: `app/build/outputs/apk/debug/app-debug.apk` APK: `app/build/outputs/apk/debug/app-debug.apk`
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает). ## Репозитории
## Лицензия | Репозиторий | Роль |
|-------------|------|
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта). | security-alert-center | Сервер API |
| ssh-monitor / RDP-login-monitor | Агенты |
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca" applicationId = "ru.kalinamall.seaca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 21 versionCode = 25
versionName = "0.5.7" versionName = "0.5.11"
} }
buildTypes { buildTypes {
+10
View File
@@ -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,21 @@
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.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()) val store = SessionStore(this)
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
}
NotificationHelper.ensureChannels(
this,
NotificationSoundResolver.resolve(this, settings),
)
} }
} }
@@ -36,6 +36,15 @@ 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
@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,9 @@ 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,
) )
@Serializable @Serializable
@@ -80,6 +83,10 @@ 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,
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 +199,23 @@ data class ProblemActionResponse(
val status: String, val status: String,
) )
@Serializable
data class LogoffActionRequest(
@SerialName("session_id") val sessionId: Int,
)
@Serializable
data class AgentCommandResponse(
@SerialName("command_uuid") val commandUuid: String,
@SerialName("command_type") val commandType: String,
val status: String,
@SerialName("result_stdout") val resultStdout: String? = null,
@SerialName("result_stderr") val resultStderr: String? = null,
val target: String? = null,
@SerialName("client_hostname") val clientHostname: String? = null,
@SerialName("internal_ip") val internalIp: String? = null,
)
@Serializable @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)
@@ -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))
@@ -106,9 +117,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 +143,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.
} }
} }
} }
@@ -30,7 +30,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,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.rdgFlapQwinstaEventId
class RdgQwinstaController(private val repo: SacRepository) {
var open by mutableStateOf(false)
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf<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 = rdgFlapQwinstaEventId(event) ?: return
targetEventId = targetId
actorUser = event.actorUser
open = true
runQwinsta(scope)
}
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
val targetId = rdgFlapQwinstaEventId(event) ?: return
targetEventId = targetId
actorUser = event.actorUser
open = true
runQwinsta(scope)
}
fun dismiss() {
if (!loading && logoffSessionId == null) {
open = false
}
}
fun runQwinsta(scope: kotlinx.coroutines.CoroutineScope) {
val targetId = targetEventId ?: return
scope.launch {
loading = true
error = null
stdout = null
sessions = emptyList()
try {
val cmd = repo.withAuth { it.postEventQwinsta(targetId) }
meta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target)
.joinToString(" · ")
.ifBlank { null }
val cmdError = agentCommandError(cmd)
if (cmdError != null) {
error = cmdError
} else {
val out = cmd.resultStdout.orEmpty()
stdout = out.ifBlank { null }
sessions = parseQwinstaSessions(out, actorUser)
}
} catch (e: Exception) {
error = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
loading = false
}
}
}
fun logoff(sessionId: Int, scope: kotlinx.coroutines.CoroutineScope, onDone: () -> Unit = {}) {
val targetId = targetEventId ?: return
scope.launch {
logoffSessionId = sessionId
error = null
try {
val cmd = repo.withAuth {
it.postEventLogoff(targetId, LogoffActionRequest(sessionId))
}
val cmdError = agentCommandError(cmd)
if (cmdError != null) {
error = cmdError
} else {
runQwinsta(scope)
}
} catch (e: Exception) {
error = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
logoffSessionId = null
onDone()
}
}
}
}
@Composable
fun rememberRdgQwinstaController(repo: SacRepository): RdgQwinstaController =
remember(repo) { RdgQwinstaController(repo) }
@Composable
fun RdgQwinstaDialog(
controller: RdgQwinstaController,
modifier: Modifier = Modifier,
) {
if (!controller.open) return
val scope = rememberCoroutineScope()
var confirmSessionId by remember { mutableIntStateOf(-1) }
var manualSessionId by remember { mutableStateOf("") }
if (confirmSessionId >= 0) {
AlertDialog(
onDismissRequest = { confirmSessionId = -1 },
title = { Text("Завершить сеанс?") },
text = { Text("Отправить logoff для сеанса ID $confirmSessionId на клиентском ПК?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val id = confirmSessionId
confirmSessionId = -1
controller.logoff(id, scope)
},
) { Text("logoff") }
},
dismissButton = {
TextButton(onClick = { confirmSessionId = -1 }) { Text("Отмена") }
},
)
}
Dialog(
onDismissRequest = { controller.dismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
) {
Column(
Modifier
.padding(16.dp)
.heightIn(max = 520.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("qwinsta / logoff", style = MaterialTheme.typography.titleLarge)
controller.meta?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
when {
controller.loading -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator()
Text("Ожидание ответа…")
}
}
controller.error != null -> {
Text(controller.error!!, color = MaterialTheme.colorScheme.error)
}
}
if (controller.sessions.isNotEmpty()) {
Text(
"Сеансы — нажмите logoff, чтобы оборвать зависшую сессию:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
controller.sessions.forEach { s ->
SessionLogoffRow(
session = s,
logoffEnabled = !controller.loading && controller.logoffSessionId == null,
logoffInProgress = controller.logoffSessionId == s.id,
onLogoff = { confirmSessionId = s.id },
)
}
} else if (!controller.loading && controller.stdout != null) {
Text(
"Сеансы не распознаны автоматически. Введите ID из вывода ниже:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = manualSessionId,
onValueChange = { manualSessionId = it.filter { ch -> ch.isDigit() } },
label = { Text("ID сеанса") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(
enabled = manualSessionId.isNotBlank() && !controller.loading && controller.logoffSessionId == null,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
manualSessionId.toIntOrNull()?.let { confirmSessionId = it }
},
) { Text("logoff") }
}
}
controller.stdout?.let { raw ->
Text("Вывод qwinsta", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(raw, style = MaterialTheme.typography.bodySmall)
}
if (controller.stdout == null && !controller.loading && controller.error == null) {
Text("Нет вывода", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(
enabled = !controller.loading && controller.logoffSessionId == null,
onClick = { controller.dismiss() },
) { Text("Закрыть") }
}
}
}
}
}
@Composable
private fun SessionLogoffRow(
session: QwinstaSessionRow,
logoffEnabled: Boolean,
logoffInProgress: Boolean,
onLogoff: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(session.sessionName, style = MaterialTheme.typography.bodyMedium)
Text(
"${session.userName} · ID ${session.id} · ${session.state}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (logoffInProgress) {
CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp))
} else {
Button(
enabled = logoffEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = onLogoff,
) { Text("logoff") }
}
}
}
}
@Composable
fun RdgQwinstaButton(
controller: RdgQwinstaController,
enabled: Boolean = true,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
OutlinedButton(
enabled = enabled && !controller.loading,
onClick = onClick,
modifier = modifier,
) {
Text(if (controller.loading) "qwinsta…" else "qwinsta")
}
}
@@ -17,7 +17,10 @@ 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.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
@@ -55,7 +58,13 @@ 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.util.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
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 +87,8 @@ 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)
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH, initialValue = DateTimeDisplayMode.SLASH,
) )
@@ -95,6 +106,17 @@ 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 (hasRdgFlapUi(d)) {
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
rdgFlapQwinstaEventId(d)?.let {
RdgQwinstaButton(
controller = qwinsta,
onClick = { qwinsta.openFor(d, scope) },
modifier = Modifier.padding(vertical = 4.dp),
)
}
}
Field("Severity", d.severity) Field("Severity", d.severity)
Field( Field(
"Тип", "Тип",
@@ -110,6 +132,8 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
EventDetailsSection(d.type, d.details, d.payload) EventDetailsSection(d.type, d.details, d.payload)
} }
} }
RdgQwinstaDialog(qwinsta)
} }
@Composable @Composable
@@ -448,9 +472,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 +489,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,7 @@ 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.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
@@ -46,8 +47,14 @@ 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.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.rdgFlapQwinstaEventId
private val REPORT_TYPE_OPTIONS = listOf( private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов", "report.daily.ssh" to "Отчёты ssh клиентов",
@@ -62,6 +69,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 +129,22 @@ 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 (hasRdgFlapUi(e)) {
style = MaterialTheme.typography.bodySmall, RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
color = MaterialTheme.colorScheme.onSurface, }
modifier = Modifier.weight(1f), Text(
) dashboardRecentEventLine(e),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
if (rdgFlapQwinstaEventId(e) != null) {
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 +158,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
} }
} }
} }
RdgQwinstaDialog(qwinsta)
} }
private fun dashboardRecentEventLine(event: EventSummary): String { private fun dashboardRecentEventLine(event: EventSummary): String {
@@ -171,6 +190,8 @@ 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)
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH, initialValue = DateTimeDisplayMode.SLASH,
) )
@@ -191,6 +212,11 @@ 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)}" },
itemRdgFlap = { hasRdgFlapUi(it) },
itemRdgFlapPairId = { it.rdgFlapPairEventId },
itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
onQwinsta = { qwinsta.openFor(it, scope) },
qwinstaLoading = qwinsta.loading,
onOpen = onOpen, onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"), severities = listOf(null, "info", "warning", "high", "critical"),
hostnameFilter = hostname, hostnameFilter = hostname,
@@ -205,6 +231,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
) )
}, },
) )
RdgQwinstaDialog(qwinsta)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -327,6 +354,11 @@ private fun <T> PagedListScreen(
itemSub: (T) -> String, itemSub: (T) -> String,
onOpen: (Long) -> Unit, onOpen: (Long) -> Unit,
severities: List<String?>, severities: List<String?>,
itemRdgFlap: ((T) -> Boolean)? = null,
itemRdgFlapPairId: ((T) -> Long?)? = null,
itemQwinsta: ((T) -> Boolean)? = null,
onQwinsta: ((T) -> Unit)? = null,
qwinstaLoading: Boolean = false,
filterLabel: String = "Severity", filterLabel: String = "Severity",
hostnameFilter: String = "", hostnameFilter: String = "",
headerContent: @Composable () -> Unit = {}, headerContent: @Composable () -> Unit = {},
@@ -385,7 +417,16 @@ 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)) },
rdgFlap = itemRdgFlap?.invoke(item) == true,
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
showQwinsta = itemQwinsta?.invoke(item) == true,
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
qwinstaLoading = qwinstaLoading,
)
} }
} }
} }
@@ -394,20 +435,44 @@ private fun <T> PagedListScreen(
} }
@Composable @Composable
private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) { private fun ListRow(
title: String,
subtitle: String,
onClick: () -> Unit,
rdgFlap: Boolean = false,
rdgFlapPairId: Long? = null,
showQwinsta: Boolean = false,
onQwinstaClick: (() -> Unit)? = null,
qwinstaLoading: Boolean = false,
) {
Card( 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 (rdgFlap) {
RdgFlapBadge(pairEventId = rdgFlapPairId)
}
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (showQwinsta && onQwinstaClick != null) {
OutlinedButton(
enabled = !qwinstaLoading,
onClick = onQwinstaClick,
) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") }
}
} }
} }
} }
@@ -0,0 +1,71 @@
package ru.kalinamall.seaca.ui.util
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.EventSummary
data class QwinstaSessionRow(
val sessionName: String,
val userName: String,
val id: Int,
val state: String,
)
fun hasRdgFlapUi(event: EventSummary): Boolean =
event.rdgFlap || event.rdgFlapQwinstaEventId != null
fun rdgFlapQwinstaEventId(event: EventSummary): Long? =
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
fun hasRdgFlapUi(event: EventDetail): Boolean =
event.rdgFlap || event.rdgFlapQwinstaEventId != null
fun rdgFlapQwinstaEventId(event: EventDetail): Long? =
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
fun parseQwinstaSessions(stdout: String, actorUser: String?): List<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() }
?: "Команда завершилась с ошибкой"
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="notification_sounds" path="." />
</paths>
+2 -2
View File
@@ -1,6 +1,6 @@
# Roadmap — Seaca # Roadmap — Seaca
Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в репозитории **security-alert-center**, фаза **v0.6 — Mobile**. Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center), фаза **v0.6 — Mobile**.
--- ---
@@ -59,7 +59,7 @@
- [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm` - [x] Регистрация 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)
+22 -29
View File
@@ -1,46 +1,39 @@
# Seaca — руководство оператора # Seaca — оператор
## Первая настройка ## Подключение
1. Получите от администратора: URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату.
- **URL API SAC** для мобильного приложения (HTTPS, путь `/api/v1/…`);
- код регистрации `sacmob_…`;
- при необходимости — логин и пароль SAC.
2. Установите APK Seaca.
3. На экране подключения введите данные → **Подключиться**.
4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
После успеха код больше не нужен — приложение запоминает сессию.
## Разделы ## Разделы
| Раздел | Содержание | | Раздел | Содержание |
|--------|------------| |--------|------------|
| Обзор | Сводка за 24 ч, открытые проблемы; потяните вниз для обновления | | Обзор | Сводка 24 ч; pull-to-refresh |
| События | Лента с фильтром по severity | | События | Лента; **RDG flap** в заголовке |
| Проблемы | Инциденты; для operator и выше — подтвердить / закрыть | | Проблемы | Ack / Resolve (monitor+) |
| Хосты | Агенты ssh-monitor / RDP-login-monitor | | Хосты | Агенты, heartbeat |
| Отчёты | Суточные отчёты SSH и RDP | | Отчёты | `report.daily.ssh` / `report.daily.rdp` |
| Моё устройство | Сервер, пользователь, режим push, звук, формат даты/времени, выход | | Моё устройство | Push, звук, дата, выход |
## Push-уведомления ## RDG flap — qwinsta / logoff
Настраиваются на экране **Моё устройство**: Пара RD Gateway **302→303** за несколько секунд:
| Режим | Поведение | 1. Событие **302** или **303** (метка **RDG flap**).
|-------|-----------| 2. **qwinsta / logoff**`POST /api/v1/events/{id}/actions/qwinsta` на SAC.
| Push и звук | Уведомление со звуком (системным или выбранным) | 3. SAC: WinRM **qwinsta** на клиентский ПК (`internal_ip`).
| Push без звука | Только banner, без звука | 4. В диалоге — сессии, **logoff** по ID.
| Без push и звука | Данные в приложении по запросу |
Нажатие на уведомление открывает карточку события или проблемы. Нужен **Windows domain admin** в настройках SAC (WinRM).
## Push
Режимы на экране «Моё устройство». Тап — карточка события или problem.
## Блокировка ## Блокировка
При **запуске** приложения и при возврате из фона может запрашиваться отпечаток / PIN устройства (если включено в системе). Биометрия/PIN при запуске и возврате в приложение.
## Потеря доступа ## Потеря доступа
Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC. Новый код или снятие отзыва устройства в веб-SAC.
Подробности для админов — в репозитории **security-alert-center**, файл `docs/seaca-mobile.md`.
-87
View File
@@ -1,87 +0,0 @@
# Seaca
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
## Связанные репозитории
| Репозиторий | Роль |
|-------------|------|
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
| ssh-monitor | Агент Linux |
| RDP-login-monitor | Агент Windows |
| Репозиторий | URL |
|-------------|-----|
| security-alert-center | https://github.com/PTah/security-alert-center |
| ssh-monitor | https://github.com/PTah/ssh-monitor |
| RDP-login-monitor | https://github.com/PTah/RDP-login-monitor |
## Стек
| Слой | Технология |
|------|------------|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Сеть | Retrofit / OkHttp, Kotlin Serialization |
| Хранение | DataStore + Android Keystore (токены, URL сервера) |
| Push | **Firebase Cloud Messaging (FCM)** |
| Минимум SDK | Android 8.0 (API 26) |
## Что есть в приложении
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
- Pull-to-refresh на обзоре и в списках
- Фильтры событий и проблем (severity, status)
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
- Выбор звука уведомления: системный или свой (ringtone picker)
- Биометрическая блокировка при **запуске** и при возврате в приложение
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб)
- Управление пользователями SAC
- Настройки каналов (Telegram, SMTP, webhook), severity, policy
- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
## Привязка телефона к серверу
1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии).
4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
## Статус
**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты)
## Документация
| Документ | Назначение |
|----------|------------|
| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
```bash
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
## Лицензия
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
-87
View File
@@ -1,87 +0,0 @@
# Seaca
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
## Связанные репозитории
| Репозиторий | Роль |
|-------------|------|
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
| ssh-monitor | Агент Linux |
| RDP-login-monitor | Агент Windows |
| Репозиторий | URL |
|-------------|-----|
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/RDP-login-monitor |
## Стек
| Слой | Технология |
|------|------------|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Сеть | Retrofit / OkHttp, Kotlin Serialization |
| Хранение | DataStore + Android Keystore (токены, URL сервера) |
| Push | **Firebase Cloud Messaging (FCM)** |
| Минимум SDK | Android 8.0 (API 26) |
## Что есть в приложении
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
- Pull-to-refresh на обзоре и в списках
- Фильтры событий и проблем (severity, status)
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
- Выбор звука уведомления: системный или свой (ringtone picker)
- Биометрическая блокировка при **запуске** и при возврате в приложение
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб)
- Управление пользователями SAC
- Настройки каналов (Telegram, SMTP, webhook), severity, policy
- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
## Привязка телефона к серверу
1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии).
4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
## Статус
**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты)
## Документация
| Документ | Назначение |
|----------|------------|
| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
```bash
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
## Лицензия
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).