chore(home): mirror from kalinamall (6398d48) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:58 +10:00
commit 9116e0472d
59 changed files with 5335 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
val secretsFile = rootProject.file("secrets.properties")
val releaseSecrets = Properties().apply {
if (secretsFile.exists()) {
secretsFile.inputStream().use { load(it) }
}
}
val releaseKeystoreConfigured = secretsFile.exists() &&
releaseSecrets.getProperty("SEACA_STORE_FILE") != null
android {
namespace = "ru.kalinamall.seaca"
compileSdk = 35
defaultConfig {
applicationId = "ru.kalinamall.seaca"
minSdk = 26
targetSdk = 35
versionCode = 30
versionName = "0.5.16"
}
signingConfigs {
if (releaseKeystoreConfigured) {
create("release") {
storeFile = rootProject.file(releaseSecrets.getProperty("SEACA_STORE_FILE"))
storePassword = releaseSecrets.getProperty("SEACA_STORE_PASSWORD")
keyAlias = releaseSecrets.getProperty("SEACA_KEY_ALIAS")
keyPassword = releaseSecrets.getProperty("SEACA_KEY_PASSWORD")
}
}
}
buildTypes {
release {
if (releaseKeystoreConfigured) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.navigation:navigation-compose:2.8.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
implementation(platform("com.google.firebase:firebase-bom:33.5.1"))
implementation("com.google.firebase:firebase-messaging-ktx")
debugImplementation("androidx.compose.ui:ui-tooling")
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "REPLACE_ME",
"storage_bucket": "REPLACE_ME.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000000000",
"android_client_info": {
"package_name": "ru.kalinamall.seaca"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "REPLACE_ME"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+1
View File
@@ -0,0 +1 @@
# Seaca defaults sufficient for debug/release without minify
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".SeacaApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Seaca">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Seaca">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="seaca" android:host="event" />
<data android:scheme="seaca" android:host="problem" />
</intent-filter>
</activity>
<service
android:name=".push.SeacaMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</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>
</manifest>
@@ -0,0 +1,335 @@
package ru.kalinamall.seaca
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateOf as composeMutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import kotlinx.coroutines.launch
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.push.FcmRegistrar
import ru.kalinamall.seaca.security.BiometricGate
import ru.kalinamall.seaca.ui.screens.ConnectScreen
import ru.kalinamall.seaca.ui.screens.DashboardScreen
import ru.kalinamall.seaca.ui.screens.DeviceScreen
import ru.kalinamall.seaca.ui.screens.EventDetailScreen
import ru.kalinamall.seaca.ui.screens.EventsScreen
import ru.kalinamall.seaca.ui.screens.HostDetailScreen
import ru.kalinamall.seaca.ui.screens.HostsScreen
import ru.kalinamall.seaca.ui.screens.ProblemDetailScreen
import ru.kalinamall.seaca.ui.screens.ProblemsScreen
import ru.kalinamall.seaca.ui.ExternalPickerHost
import ru.kalinamall.seaca.ui.RingtonePicker
import ru.kalinamall.seaca.ui.screens.ReportsScreen
import ru.kalinamall.seaca.ui.theme.SeacaTheme
class MainActivity : FragmentActivity(), ExternalPickerHost {
private lateinit var repo: SacRepository
private val biometricUnlocked = mutableStateOf(true)
private var pendingBiometric = false
private var externalPickerFlowActive = false
private val deepLinkState = composeMutableStateOf<String?>(null)
override fun beginExternalPickerFlow() {
externalPickerFlowActive = true
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
repo = SacRepository(applicationContext)
requestNotificationPermission()
deepLinkState.value = extractDeepLink(intent)
if (shouldRequireBiometric()) {
biometricUnlocked.value = false
pendingBiometric = true
}
setContent {
val unlocked by biometricUnlocked
SeacaApp(
repo = repo,
deepLink = deepLinkState.value,
onDeepLinkConsumed = { deepLinkState.value = null },
biometricUnlocked = unlocked,
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
deepLinkState.value = extractDeepLink(intent)
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@Suppress("DEPRECATION")
super.onActivityResult(requestCode, resultCode, data)
RingtonePicker.handleActivityResult(this, requestCode, resultCode, data)
}
override fun onStop() {
super.onStop()
if (externalPickerFlowActive) return
if (shouldRequireBiometric()) {
biometricUnlocked.value = false
pendingBiometric = true
}
}
override fun onResume() {
super.onResume()
if (externalPickerFlowActive) {
externalPickerFlowActive = false
return
}
if (pendingBiometric && !biometricUnlocked.value && repo.session.getAccessToken() != null) {
pendingBiometric = false
BiometricGate.authenticate(
activity = this,
onSuccess = { biometricUnlocked.value = true },
onFailure = { finish() },
)
}
}
private fun shouldRequireBiometric(): Boolean =
repo.session.getAccessToken() != null && BiometricGate.canAuthenticate(this)
private fun extractDeepLink(intent: Intent?): String? {
intent?.getStringExtra("deep_link")?.let { return it }
val uri: Uri = intent?.data ?: return null
val id = uri.lastPathSegment ?: return null
return when (uri.host) {
"event" -> "event/$id"
"problem" -> "problem/$id"
else -> null
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) {
return
}
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
@Composable
private fun SeacaApp(
repo: SacRepository,
deepLink: String?,
onDeepLinkConsumed: () -> Unit,
biometricUnlocked: Boolean,
) {
val context = LocalContext.current
val loggedIn by repo.session.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
val nav = rememberNavController()
val scope = rememberCoroutineScope()
LaunchedEffect(loggedIn) {
if (loggedIn) {
FcmRegistrar.registerIfPossible(context, repo)
}
}
LaunchedEffect(deepLink, loggedIn, biometricUnlocked) {
if (!loggedIn || !biometricUnlocked || deepLink.isNullOrBlank()) return@LaunchedEffect
when {
deepLink.startsWith("event/") -> {
nav.navigate("event/${deepLink.removePrefix("event/")}")
onDeepLinkConsumed()
}
deepLink.startsWith("problem/") -> {
nav.navigate("problem/${deepLink.removePrefix("problem/")}")
onDeepLinkConsumed()
}
}
}
val canOperate = remember(loggedIn) {
val role = repo.session.getRole()
role == "operator" || role == "admin"
}
var mainTabIndex by rememberSaveable { mutableIntStateOf(MainTab.Dashboard.ordinal) }
var dashboardRefreshTick by rememberSaveable { mutableIntStateOf(0) }
val mainTab = MainTab.entries[mainTabIndex.coerceIn(MainTab.entries.indices)]
SeacaTheme {
if (!loggedIn) {
ConnectScreen(
repo = repo,
onConnected = {
scope.launch { FcmRegistrar.registerIfPossible(context, repo) }
},
)
return@SeacaTheme
}
if (!biometricUnlocked) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
return@SeacaTheme
}
NavHost(navController = nav, startDestination = "main") {
composable("main") {
MainShell(
repo = repo,
selectedTab = mainTab,
dashboardRefreshTick = dashboardRefreshTick,
onTabSelected = { tab ->
mainTabIndex = tab.ordinal
if (tab == MainTab.Dashboard) {
dashboardRefreshTick++
}
},
onOpenEvent = { nav.navigate("event/$it") },
onOpenProblem = { nav.navigate("problem/$it") },
onOpenHost = { nav.navigate("host/$it") },
onOpenDevice = { nav.navigate("device") },
)
}
composable(
"event/{id}",
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
EventDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() }
}
composable(
"problem/{id}",
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
ProblemDetailScreen(
repo = repo,
id = entry.arguments?.getLong("id") ?: 0L,
canOperate = canOperate,
onBack = { nav.popBackStack() },
onChanged = { },
)
}
composable(
"host/{id}",
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
HostDetailScreen(
repo = repo,
id = entry.arguments?.getLong("id") ?: 0L,
onOpenEvent = { nav.navigate("event/$it") },
onBack = { nav.popBackStack() },
)
}
composable("device") {
DeviceScreen(repo) { nav.popBackStack("main", false) }
}
}
}
}
private enum class MainTab(val label: String) {
Dashboard("Обзор"),
Events("События"),
Problems("Проблемы"),
Hosts("Хосты"),
Reports("Отчёты"),
}
@Composable
private fun MainShell(
repo: SacRepository,
selectedTab: MainTab,
dashboardRefreshTick: Int,
onTabSelected: (MainTab) -> Unit,
onOpenEvent: (Long) -> Unit,
onOpenProblem: (Long) -> Unit,
onOpenHost: (Long) -> Unit,
onOpenDevice: () -> Unit,
) {
Scaffold(
bottomBar = {
NavigationBar {
MainTab.entries.forEach { item ->
NavigationBarItem(
selected = selectedTab == item,
onClick = { onTabSelected(item) },
icon = {
Icon(
when (item) {
MainTab.Dashboard -> Icons.Default.Dashboard
MainTab.Events -> Icons.Default.Warning
MainTab.Problems -> Icons.Default.Error
MainTab.Hosts -> Icons.Default.Computer
MainTab.Reports -> Icons.Default.Description
},
contentDescription = item.label,
)
},
label = { Text(item.label) },
)
}
NavigationBarItem(
selected = false,
onClick = onOpenDevice,
icon = { Icon(Icons.Default.PhoneAndroid, contentDescription = "Устройство") },
label = { Text("Устройство") },
)
}
},
) { padding ->
Box(Modifier.padding(padding)) {
when (selectedTab) {
MainTab.Dashboard -> DashboardScreen(repo, refreshKey = dashboardRefreshTick)
MainTab.Events -> EventsScreen(repo, onOpenEvent)
MainTab.Problems -> ProblemsScreen(repo, onOpenProblem)
MainTab.Hosts -> HostsScreen(repo, onOpenHost)
MainTab.Reports -> ReportsScreen(repo, onOpenEvent)
}
}
}
}
@@ -0,0 +1,23 @@
package ru.kalinamall.seaca
import android.app.Application
import kotlinx.coroutines.runBlocking
import ru.kalinamall.seaca.data.SessionStore
import ru.kalinamall.seaca.push.AppForegroundState
import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
class SeacaApplication : Application() {
override fun onCreate() {
super.onCreate()
AppForegroundState.bind(this)
val store = SessionStore(this)
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
}
NotificationHelper.ensureChannels(
this,
NotificationSoundResolver.resolve(this, settings),
)
}
}
@@ -0,0 +1,18 @@
package ru.kalinamall.seaca.data
enum class DateTimeDisplayMode(
val storageKey: String,
val label: String,
val pattern: String?,
) {
SLASH("slash", "ДД/ММ/ГГГГ ЧЧ:мм:сс", "dd/MM/yyyy HH:mm:ss"),
DASH("dash", "ДД-ММ-ГГГГ ЧЧ:мм:сс", "dd-MM-yyyy HH:mm:ss"),
DOT("dot", "ДД.ММ.ГГГГ ЧЧ:мм:сс", "dd.MM.yyyy HH:mm:ss"),
LOCALE("locale", "Как в системе телефона", null),
;
companion object {
fun fromStorage(value: String?): DateTimeDisplayMode =
entries.firstOrNull { it.storageKey == value } ?: SLASH
}
}
@@ -0,0 +1,16 @@
package ru.kalinamall.seaca.data
enum class NotificationMode(
val storageKey: String,
val label: String,
) {
PUSH_SOUND("push_sound", "Push и звук"),
PUSH_SILENT("push_silent", "Push без звука"),
OFF("off", "Без push и звука"),
;
companion object {
fun fromStorage(value: String?): NotificationMode =
entries.firstOrNull { it.storageKey == value } ?: PUSH_SOUND
}
}
@@ -0,0 +1,20 @@
package ru.kalinamall.seaca.data
enum class NotificationSoundSource(
val storageKey: String,
val label: String,
) {
SYSTEM("system", "Системный звук"),
CUSTOM("custom", "Свой звук"),
;
companion object {
fun fromStorage(value: String?): NotificationSoundSource =
entries.firstOrNull { it.storageKey == value } ?: SYSTEM
}
}
data class NotificationSoundSettings(
val source: NotificationSoundSource,
val customUri: String?,
)
@@ -0,0 +1,92 @@
package ru.kalinamall.seaca.data
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface SacApi {
@GET("health")
suspend fun health(): HealthResponse
@POST("api/v1/mobile/enroll")
suspend fun enroll(@Body body: EnrollRequest): AuthResponse
@POST("api/v1/mobile/auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): AuthResponse
@PUT("api/v1/mobile/devices/me/fcm")
suspend fun updateFcmToken(@Body body: FcmTokenRequest)
@GET("api/v1/dashboards/summary")
suspend fun dashboardSummary(): DashboardSummary
@GET("api/v1/events")
suspend fun listEvents(
@Query("page") page: Int = 1,
@Query("page_size") pageSize: Int = 30,
@Query("severity") severity: String? = null,
@Query("type") type: String? = null,
@Query("host_id") hostId: Long? = null,
@Query("include_hidden") includeHidden: Boolean? = null,
@Query("hostname") hostname: String? = null,
@Query("q") q: String? = null,
): EventListResponse
@GET("api/v1/events/{id}")
suspend fun getEvent(@Path("id") id: Long): EventDetail
@POST("api/v1/events/{id}/actions/qwinsta")
suspend fun postEventQwinsta(@Path("id") id: Long): AgentCommandResponse
@POST("api/v1/events/{id}/actions/logoff")
suspend fun postEventLogoff(
@Path("id") id: Long,
@Body body: LogoffActionRequest,
): AgentCommandResponse
@POST("api/v1/events/{id}/actions/terminate-session")
suspend fun postEventTerminateSession(
@Path("id") id: Long,
@Body body: EventSessionTerminateRequest = EventSessionTerminateRequest(),
): SessionActionResponse
@POST("api/v1/hosts/{id}/actions/sessions/list")
suspend fun postHostSessionsList(@Path("id") id: Long): HostSessionsResponse
@POST("api/v1/hosts/{id}/actions/sessions/terminate")
suspend fun postHostSessionTerminate(
@Path("id") id: Long,
@Body body: HostSessionTerminateRequest,
): SessionActionResponse
@GET("api/v1/problems")
suspend fun listProblems(
@Query("page") page: Int = 1,
@Query("page_size") pageSize: Int = 30,
@Query("status") status: String? = null,
@Query("severity") severity: String? = null,
@Query("hostname") hostname: String? = null,
): ProblemListResponse
@GET("api/v1/problems/{id}")
suspend fun getProblem(@Path("id") id: Long): ProblemDetail
@POST("api/v1/problems/{id}/ack")
suspend fun ackProblem(@Path("id") id: Long): ProblemActionResponse
@POST("api/v1/problems/{id}/resolve")
suspend fun resolveProblem(@Path("id") id: Long): ProblemActionResponse
@GET("api/v1/hosts")
suspend fun listHosts(
@Query("page") page: Int = 1,
@Query("page_size") pageSize: Int = 30,
@Query("hostname") hostname: String? = null,
): HostListResponse
@GET("api/v1/hosts/{id}")
suspend fun getHost(@Path("id") id: Long): HostDetail
}
@@ -0,0 +1,265 @@
package ru.kalinamall.seaca.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class HealthResponse(
val status: String = "",
val database: String? = null,
val version: String? = null,
)
@Serializable
data class EnrollRequest(
@SerialName("enrollment_code") val enrollmentCode: String,
val username: String? = null,
val password: String? = null,
@SerialName("device_uuid") val deviceUuid: String,
@SerialName("display_name") val displayName: String = "Android",
val platform: String = "android",
@SerialName("app_version") val appVersion: String,
@SerialName("fcm_token") val fcmToken: String? = null,
)
@Serializable
data class RefreshRequest(
@SerialName("refresh_token") val refreshToken: String,
@SerialName("device_uuid") val deviceUuid: String,
)
@Serializable
data class AuthResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("token_type") val tokenType: String = "bearer",
@SerialName("device_id") val deviceId: Int,
val username: String,
val role: String,
)
@Serializable
data class FcmTokenRequest(
@SerialName("fcm_token") val fcmToken: String,
)
@Serializable
data class EventSummary(
val id: Long,
@SerialName("event_id") val eventId: String,
@SerialName("host_id") val hostId: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("occurred_at") val occurredAt: String,
@SerialName("received_at") val receivedAt: String? = null,
val type: String,
val severity: String,
val title: String,
val summary: String,
@SerialName("actor_user") val actorUser: String? = null,
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
)
@Serializable
data class EventListResponse(
val items: List<EventSummary>,
val total: Int,
val page: Int,
@SerialName("page_size") val pageSize: Int,
)
@Serializable
data class EventDetail(
val id: Long,
@SerialName("event_id") val eventId: String,
@SerialName("host_id") val hostId: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("occurred_at") val occurredAt: String,
val type: String,
val severity: String,
val title: String,
val summary: String,
@SerialName("actor_user") val actorUser: String? = null,
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
val details: JsonElement? = null,
val raw: JsonElement? = null,
val payload: JsonElement? = null,
)
@Serializable
data class ProblemSummary(
val id: Long,
@SerialName("host_id") val hostId: Long? = null,
val hostname: String? = null,
val title: String,
val summary: String,
val severity: String,
val status: String,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String? = null,
)
@Serializable
data class ProblemListResponse(
val items: List<ProblemSummary>,
val total: Int,
val page: Int,
@SerialName("page_size") val pageSize: Int,
)
@Serializable
data class ProblemEventItem(
val id: Long,
@SerialName("event_id") val eventId: String,
@SerialName("occurred_at") val occurredAt: String,
val type: String,
val severity: String,
val title: String,
val summary: String,
)
@Serializable
data class ProblemDetail(
val id: Long,
val title: String,
val summary: String,
val severity: String,
val status: String,
val hostname: String? = null,
val events: List<ProblemEventItem> = emptyList(),
)
@Serializable
data class HostSummary(
val id: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("os_family") val osFamily: String,
val product: String,
@SerialName("agent_status") val agentStatus: String,
@SerialName("last_seen_at") val lastSeenAt: String,
)
@Serializable
data class HostListResponse(
val items: List<HostSummary>,
val total: Int,
val page: Int,
@SerialName("page_size") val pageSize: Int,
)
@Serializable
data class HostDetail(
val id: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("os_family") val osFamily: String,
@SerialName("os_version") val osVersion: String? = null,
val product: String,
@SerialName("product_version") val productVersion: String? = null,
@SerialName("agent_status") val agentStatus: String,
@SerialName("last_seen_at") val lastSeenAt: String,
val inventory: JsonElement? = null,
)
@Serializable
data class TopHostItem(
@SerialName("host_id") val hostId: Long,
val hostname: String,
val count: Int,
)
@Serializable
data class TopTypeItem(
val type: String,
val count: Int,
)
@Serializable
data class DashboardSummary(
@SerialName("events_last_24h") val eventsLast24h: Int,
@SerialName("hosts_total") val hostsTotal: Int,
@SerialName("hosts_stale") val hostsStale: Int,
@SerialName("problems_open") val problemsOpen: Int,
@SerialName("severity_24h") val severity24h: Map<String, Int> = emptyMap(),
@SerialName("top_hosts") val topHosts: List<TopHostItem> = emptyList(),
@SerialName("top_event_types") val topEventTypes: List<TopTypeItem> = emptyList(),
@SerialName("recent_events") val recentEvents: List<EventSummary> = emptyList(),
)
@Serializable
data class ProblemActionResponse(
val id: Long,
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
data class ApiErrorBody(
val detail: String? = null,
)
@@ -0,0 +1,152 @@
package ru.kalinamall.seaca.data
import android.content.Context
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import ru.kalinamall.seaca.BuildConfig
import ru.kalinamall.seaca.security.TlsTrust
import java.util.concurrent.TimeUnit
class SacRepository(context: Context) {
val session = SessionStore(context.applicationContext)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
@Volatile
private var api: SacApi? = null
@Volatile
private var baseUrl: String? = null
suspend fun ensureClient(): SacApi {
val url = session.getBaseUrl() ?: error("Не задан URL сервера")
if (api == null || baseUrl != url) {
baseUrl = url
api = buildApi(url, session.trustAllCerts())
}
return api!!
}
suspend fun checkHealth(serverUrl: String, trustAll: Boolean): HealthResponse {
val normalized = normalizeUrl(serverUrl)
return buildApi(normalized, trustAll).health()
}
suspend fun enroll(
serverUrl: String,
enrollmentCode: String,
username: String?,
password: String?,
displayName: String,
fcmToken: String?,
trustAll: Boolean,
): AuthResponse {
val normalized = normalizeUrl(serverUrl)
val deviceUuid = session.getDeviceUuid()
val response = buildApi(normalized, trustAll).enroll(
EnrollRequest(
enrollmentCode = enrollmentCode.trim(),
username = username?.trim()?.ifBlank { null },
password = password?.ifBlank { null },
deviceUuid = deviceUuid,
displayName = displayName,
appVersion = BuildConfig.VERSION_NAME,
fcmToken = fcmToken,
),
)
session.saveSession(normalized, response)
if (trustAll) session.setTrustAllCerts(true)
api = buildApi(normalized, trustAll)
baseUrl = normalized
return response
}
suspend fun <T> withAuth(block: suspend (SacApi) -> T): T {
val client = ensureClient()
return try {
block(client)
} catch (e: retrofit2.HttpException) {
if (e.code() == 401) {
refreshSession()
block(ensureClient())
} else {
throw e
}
}
}
private suspend fun refreshSession() {
val refresh = session.getRefreshToken() ?: throw IllegalStateException("Сессия истекла")
val uuid = session.getDeviceUuid()
val url = session.getBaseUrl() ?: error("Нет URL")
val response = buildApi(url, session.trustAllCerts()).refresh(
RefreshRequest(refreshToken = refresh, deviceUuid = uuid),
)
session.updateTokens(response)
api = buildApi(url, session.trustAllCerts())
}
suspend fun registerFcmToken(token: String) {
withAuth { it.updateFcmToken(FcmTokenRequest(token)) }
}
suspend fun logout() {
api = null
baseUrl = null
session.clear()
}
private fun buildApi(url: String, trustAll: Boolean): SacApi {
val authInterceptor = Interceptor { chain ->
val token = session.getAccessToken()
val request = if (token != null) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
chain.proceed(request)
}
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
val okhttp = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.build()
return Retrofit.Builder()
.baseUrl(url.trimEnd('/') + "/")
.client(okhttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(SacApi::class.java)
}
private fun normalizeUrl(raw: String): String {
var u = raw.trim()
if (!u.startsWith("http://") && !u.startsWith("https://")) {
u = "https://$u"
}
return u.removeSuffix("/")
}
companion object {
fun parseError(e: retrofit2.HttpException): String {
val body = e.response()?.errorBody()?.string().orEmpty()
return try {
Json.decodeFromString<ApiErrorBody>(body).detail ?: body
} catch (_: Exception) {
body.ifBlank { "HTTP ${e.code()}" }
}
}
}
}
@@ -0,0 +1,151 @@
package ru.kalinamall.seaca.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.UUID
private val Context.dataStore by preferencesDataStore("seaca_prefs")
class SessionStore(private val context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs = EncryptedSharedPreferences.create(
context,
"seaca_secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
!prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS)
}
val notificationMode: Flow<NotificationMode> = context.dataStore.data.map { prefs ->
NotificationMode.fromStorage(prefs[KEY_NOTIFICATION_MODE])
}
val dateTimeDisplayMode: Flow<DateTimeDisplayMode> = context.dataStore.data.map { prefs ->
DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT])
}
val notificationSoundSettings: Flow<NotificationSoundSettings> = context.dataStore.data.map { prefs ->
NotificationSoundSettings(
source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]),
customUri = prefs[KEY_NOTIFICATION_SOUND_URI],
)
}
suspend fun getNotificationMode(): NotificationMode =
NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE])
suspend fun getDateTimeDisplayMode(): DateTimeDisplayMode =
DateTimeDisplayMode.fromStorage(context.dataStore.data.first()[KEY_DATE_TIME_FORMAT])
suspend fun getNotificationSoundSettings(): NotificationSoundSettings {
val prefs = context.dataStore.data.first()
return NotificationSoundSettings(
source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]),
customUri = prefs[KEY_NOTIFICATION_SOUND_URI],
)
}
suspend fun setNotificationMode(mode: NotificationMode) {
context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey }
}
suspend fun setDateTimeDisplayMode(mode: DateTimeDisplayMode) {
context.dataStore.edit { it[KEY_DATE_TIME_FORMAT] = mode.storageKey }
}
suspend fun setNotificationSound(source: NotificationSoundSource, customUri: String?) {
context.dataStore.edit {
it[KEY_NOTIFICATION_SOUND_SOURCE] = source.storageKey
if (customUri.isNullOrBlank()) {
it.remove(KEY_NOTIFICATION_SOUND_URI)
} else {
it[KEY_NOTIFICATION_SOUND_URI] = customUri
}
}
}
suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL]
suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false
suspend fun setTrustAllCerts(value: Boolean) {
context.dataStore.edit { it[KEY_TRUST_ALL] = value }
}
suspend fun getDeviceUuid(): String {
val existing = context.dataStore.data.first()[KEY_DEVICE_UUID]
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
context.dataStore.edit { it[KEY_DEVICE_UUID] = fresh }
return fresh
}
fun getAccessToken(): String? = securePrefs.getString(KEY_ACCESS, null)
fun getRefreshToken(): String? = securePrefs.getString(KEY_REFRESH, null)
fun getDeviceId(): Int = securePrefs.getInt(KEY_DEVICE_ID, 0)
fun getUsername(): String? = securePrefs.getString(KEY_USERNAME, null)
fun getRole(): String? = securePrefs.getString(KEY_ROLE, null)
suspend fun saveSession(baseUrl: String, auth: AuthResponse) {
val normalized = baseUrl.trim().removeSuffix("/")
context.dataStore.edit { it[KEY_BASE_URL] = normalized }
securePrefs.edit()
.putString(KEY_ACCESS, auth.accessToken)
.putString(KEY_REFRESH, auth.refreshToken)
.putInt(KEY_DEVICE_ID, auth.deviceId)
.putString(KEY_USERNAME, auth.username)
.putString(KEY_ROLE, auth.role)
.apply()
}
suspend fun updateTokens(auth: AuthResponse) {
securePrefs.edit()
.putString(KEY_ACCESS, auth.accessToken)
.putString(KEY_REFRESH, auth.refreshToken)
.putInt(KEY_DEVICE_ID, auth.deviceId)
.apply()
}
suspend fun clear() {
context.dataStore.edit {
it.remove(KEY_BASE_URL)
it.remove(KEY_TRUST_ALL)
}
securePrefs.edit().clear().apply()
}
companion object {
private val KEY_BASE_URL = stringPreferencesKey("base_url")
private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid")
private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs")
private val KEY_NOTIFICATION_MODE = stringPreferencesKey("notification_mode")
private val KEY_DATE_TIME_FORMAT = stringPreferencesKey("date_time_format")
private val KEY_NOTIFICATION_SOUND_SOURCE = stringPreferencesKey("notification_sound_source")
private val KEY_NOTIFICATION_SOUND_URI = stringPreferencesKey("notification_sound_uri")
private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_USERNAME = "username"
private const val KEY_ROLE = "role"
}
}
@@ -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
}
@@ -0,0 +1,26 @@
package ru.kalinamall.seaca.push
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
import ru.kalinamall.seaca.data.SacRepository
object FcmRegistrar {
suspend fun registerIfPossible(context: android.content.Context, repo: SacRepository) {
if (repo.session.getAccessToken() == null) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) return
}
runCatching {
val token = FirebaseMessaging.getInstance().token.await()
repo.registerFcmToken(token)
}
}
}
@@ -0,0 +1,155 @@
package ru.kalinamall.seaca.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import ru.kalinamall.seaca.MainActivity
import ru.kalinamall.seaca.R
object NotificationHelper {
private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
private const val CHANNEL_HIGH_SILENT = "sac_high_silent"
private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical")
private var appliedSoundKey: String? = null
private var soundChannelSuffix: String = "sys"
private var channelDefault = "sac_default_sys"
private var channelHigh = "sac_high_sys"
private var channelCritical = "sac_critical_sys"
fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val soundKey = soundUri.toString()
val mgr = context.getSystemService(NotificationManager::class.java)
if (soundKey != appliedSoundKey) {
legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
if (appliedSoundKey != null) {
mgr.deleteNotificationChannel(channelDefault)
mgr.deleteNotificationChannel(channelHigh)
mgr.deleteNotificationChannel(channelCritical)
}
appliedSoundKey = soundKey
soundChannelSuffix = soundKey.hashCode().toUInt().toString(16).take(8)
channelDefault = "sac_default_$soundChannelSuffix"
channelHigh = "sac_high_$soundChannelSuffix"
channelCritical = "sac_critical_$soundChannelSuffix"
}
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
mgr.createNotificationChannel(
soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
soundChannel(context, channelCritical, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
)
mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent))
mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent))
mgr.createNotificationChannel(silentChannel(context, CHANNEL_CRITICAL_SILENT, R.string.channel_critical_silent))
}
private fun soundChannel(
context: Context,
id: String,
nameRes: Int,
importance: Int,
soundUri: Uri,
audioAttributes: AudioAttributes,
): NotificationChannel = NotificationChannel(id, context.getString(nameRes), importance).apply {
setSound(soundUri, audioAttributes)
enableVibration(true)
}
private fun silentChannel(context: Context, id: String, nameRes: Int): NotificationChannel =
NotificationChannel(id, context.getString(nameRes), NotificationManager.IMPORTANCE_DEFAULT).apply {
setSound(null, null)
enableVibration(false)
setShowBadge(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setAllowBubbles(false)
}
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
}
fun clearAll(context: Context) {
context.getSystemService(NotificationManager::class.java).cancelAll()
}
fun show(
context: Context,
title: String,
body: String,
severity: String?,
kind: String?,
id: String?,
silent: Boolean = false,
soundUri: Uri = NotificationSoundResolver.defaultNotificationUri(),
) {
ensureChannels(context, soundUri)
val channel = resolveChannel(severity, silent)
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
when (kind) {
"event" -> putExtra("deep_link", "event/$id")
"problem" -> putExtra("deep_link", "problem/$id")
}
}
val pending = PendingIntent.getActivity(
context,
(id ?: title).hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pending)
.setPriority(
when {
silent -> NotificationCompat.PRIORITY_DEFAULT
channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT ->
NotificationCompat.PRIORITY_MAX
channel == channelHigh || channel == CHANNEL_HIGH_SILENT ->
NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
},
)
if (silent) {
builder.setSilent(true)
builder.setSound(null)
builder.setVibrate(null)
builder.setDefaults(0)
}
context.getSystemService(NotificationManager::class.java)
.notify((id ?: title).hashCode(), builder.build())
}
private fun resolveChannel(severity: String?, silent: Boolean): String {
if (silent) {
return when (severity?.lowercase()) {
"critical" -> CHANNEL_CRITICAL_SILENT
"high" -> CHANNEL_HIGH_SILENT
else -> CHANNEL_DEFAULT_SILENT
}
}
return when (severity?.lowercase()) {
"critical" -> channelCritical
"high" -> channelHigh
else -> channelDefault
}
}
}
@@ -0,0 +1,109 @@
package ru.kalinamall.seaca.push
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import androidx.core.content.FileProvider
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.SessionStore
import java.io.File
object NotificationSoundResolver {
private const val CUSTOM_SOUND_FILE = "custom_notification_sound"
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
return when (settings.source) {
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
NotificationSoundSource.CUSTOM -> {
playableCustomUri(context, settings.customUri) ?: defaultNotificationUri()
}
}
}
suspend fun normalizeStoredSettings(context: Context, store: SessionStore): NotificationSoundSettings {
val settings = store.getNotificationSoundSettings()
if (settings.source != NotificationSoundSource.CUSTOM || settings.customUri.isNullOrBlank()) {
return settings
}
if (isInternalSoundUri(context, settings.customUri)) {
return settings
}
val imported = importCustomSound(context, Uri.parse(settings.customUri)) ?: return settings
store.setNotificationSound(NotificationSoundSource.CUSTOM, imported)
return store.getNotificationSoundSettings()
}
fun defaultNotificationUri(): Uri =
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
fun displayName(context: Context, settings: NotificationSoundSettings): String {
return try {
val uri = resolve(context, settings)
RingtoneManager.getRingtone(context, uri)?.getTitle(context) ?: ""
} catch (_: Exception) {
""
}
}
/**
* Copies picker/system URI into app storage so FCM in a fresh process can read it.
* Returns stored URI string (FileProvider or android.resource).
*/
fun importCustomSound(context: Context, source: Uri): String? {
if (ContentResolver.SCHEME_ANDROID_RESOURCE == source.scheme) {
return source.toString()
}
if (defaultNotificationUri().toString() == source.toString()) {
return source.toString()
}
persistCustomUri(context, source)
val dest = File(context.filesDir, CUSTOM_SOUND_FILE)
return try {
context.contentResolver.openInputStream(source)?.use { input ->
dest.outputStream().use { output -> input.copyTo(output) }
} ?: return null
internalFileUri(context, dest).toString()
} catch (_: Exception) {
null
}
}
private fun playableCustomUri(context: Context, stored: String?): Uri? {
if (stored.isNullOrBlank()) return null
val parsed = Uri.parse(stored)
if (isInternalSoundUri(context, stored)) {
val file = File(context.filesDir, CUSTOM_SOUND_FILE)
return if (file.isFile) internalFileUri(context, file) else null
}
if (ContentResolver.SCHEME_ANDROID_RESOURCE == parsed.scheme) {
return parsed
}
return importCustomSound(context, parsed)?.let { Uri.parse(it) }
}
private fun isInternalSoundUri(context: Context, stored: String): Boolean {
val parsed = Uri.parse(stored)
return parsed.authority == fileProviderAuthority(context)
}
private fun internalFileUri(context: Context, file: File): Uri =
FileProvider.getUriForFile(context, fileProviderAuthority(context), file)
private fun fileProviderAuthority(context: Context): String =
"${context.packageName}.fileprovider"
private fun persistCustomUri(context: Context, uri: Uri) {
if (uri.scheme != ContentResolver.SCHEME_CONTENT) return
try {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (_: SecurityException) {
// Best-effort; internal copy is the reliable path.
}
}
}
@@ -0,0 +1,60 @@
package ru.kalinamall.seaca.push
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.SacRepository
class SeacaMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val repo by lazy { SacRepository(applicationContext) }
override fun onMessageReceived(message: RemoteMessage) {
if (AppForegroundState.isInForeground()) return
val mode = runBlocking { repo.session.getNotificationMode() }
when (mode) {
NotificationMode.OFF -> return
NotificationMode.PUSH_SILENT -> show(message, silent = true)
NotificationMode.PUSH_SOUND -> show(message, silent = false)
}
}
private fun show(message: RemoteMessage, silent: Boolean) {
val data = message.data
val title = data["title"] ?: message.notification?.title ?: "SAC"
val body = data["body"] ?: message.notification?.body ?: ""
val soundUri = if (silent) {
NotificationSoundResolver.defaultNotificationUri()
} else {
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session)
}
NotificationSoundResolver.resolve(this, settings)
}
NotificationHelper.show(
context = this,
title = title,
body = body,
severity = data["severity"],
kind = data["kind"],
id = data["id"],
silent = silent,
soundUri = soundUri,
)
}
override fun onNewToken(token: String) {
scope.launch {
runCatching {
if (repo.session.getAccessToken() != null) {
repo.registerFcmToken(token)
}
}
}
}
}
@@ -0,0 +1,48 @@
package ru.kalinamall.seaca.security
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
object BiometricGate {
fun canAuthenticate(activity: FragmentActivity): Boolean {
val mgr = BiometricManager.from(activity)
return mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(
activity: FragmentActivity,
onSuccess: () -> Unit,
onFailure: () -> Unit,
) {
if (!canAuthenticate(activity)) {
onSuccess()
return
}
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onFailure()
}
override fun onAuthenticationFailed() = Unit
},
)
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Seaca")
.setSubtitle("Подтвердите доступ")
.setNegativeButtonText("Отмена")
.build(),
)
}
}
@@ -0,0 +1,38 @@
package ru.kalinamall.seaca.security
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
object TlsTrust {
private val permissiveManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
private val defaultManager: X509TrustManager by lazy {
val tmf = javax.net.ssl.TrustManagerFactory.getInstance(
javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm(),
)
tmf.init(null as java.security.KeyStore?)
tmf.trustManagers.filterIsInstance<X509TrustManager>().first()
}
fun trustManager(trustAll: Boolean): X509TrustManager = if (trustAll) permissiveManager else defaultManager
fun socketFactory(trustAll: Boolean): SSLSocketFactory {
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf<TrustManager>(trustManager(trustAll)), SecureRandom())
return ctx.socketFactory
}
fun hostnameVerifier(trustAll: Boolean): HostnameVerifier =
if (trustAll) HostnameVerifier { _, _ -> true } else HostnameVerifier { host, session ->
javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session)
}
}
@@ -0,0 +1,61 @@
package ru.kalinamall.seaca.ui
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import ru.kalinamall.seaca.push.NotificationSoundResolver
object RingtonePicker {
private const val REQUEST_CODE = 9001
private var callback: (suspend (Uri?) -> Unit)? = null
fun launch(activity: FragmentActivity, currentUri: Uri, onResult: suspend (Uri?) -> Unit) {
callback = onResult
(activity as? ExternalPickerHost)?.beginExternalPickerFlow()
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false)
putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, NotificationSoundResolver.defaultNotificationUri())
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, currentUri)
}
try {
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, REQUEST_CODE)
} catch (_: ActivityNotFoundException) {
callback = null
throw ActivityNotFoundException()
}
}
fun handleActivityResult(activity: FragmentActivity, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != REQUEST_CODE) return
val onResult = callback ?: return
callback = null
val uri = if (resultCode == Activity.RESULT_OK) readResult(data) else null
activity.lifecycleScope.launch {
runCatching { onResult(uri) }
}
}
private fun readResult(data: Intent?): Uri? {
if (data == null) return null
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
} else {
@Suppress("DEPRECATION")
data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
}
}
interface ExternalPickerHost {
fun beginExternalPickerFlow()
}
@@ -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,48 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun BackArrowButton(
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад",
tint = if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
@Composable
fun ForwardArrowButton(
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Вперёд",
tint = if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
@@ -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 "Оборвать сессию")
}
}
@@ -0,0 +1,125 @@
package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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 ru.kalinamall.seaca.data.SacRepository
@Composable
fun ConnectScreen(
repo: SacRepository,
onConnected: () -> Unit,
) {
var serverUrl by remember { mutableStateOf("") }
var enrollmentCode by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf(android.os.Build.MODEL) }
var trustCert by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Seaca", style = MaterialTheme.typography.headlineMedium)
Text("Подключение к Security Alert Center", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("URL сервера") },
placeholder = { Text("https://host.example") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = enrollmentCode,
onValueChange = { enrollmentCode = it },
label = { Text("Код регистрации") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Логин SAC (если нужен)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Пароль SAC") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Имя устройства") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Column(horizontalAlignment = Alignment.Start) {
Checkbox(checked = trustCert, onCheckedChange = { trustCert = it })
Text("Доверять сертификату (внутренняя сеть / self-signed)")
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
loading = true
error = null
scope.launch {
try {
repo.checkHealth(serverUrl, trustCert)
repo.enroll(
serverUrl = serverUrl,
enrollmentCode = enrollmentCode,
username = username,
password = password,
displayName = displayName,
fcmToken = null,
trustAll = trustCert,
)
onConnected()
} catch (e: Exception) {
error = when (e) {
is retrofit2.HttpException -> SacRepository.parseError(e)
else -> e.message ?: "Ошибка подключения"
}
} finally {
loading = false
}
}
},
enabled = !loading && serverUrl.isNotBlank() && enrollmentCode.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (loading) "Подключение…" else "Подключиться")
}
}
}
@@ -0,0 +1,821 @@
package ru.kalinamall.seaca.ui.screens
import android.content.ActivityNotFoundException
import android.widget.Toast
import androidx.compose.runtime.rememberUpdatedState
import androidx.fragment.app.FragmentActivity
import android.media.RingtoneManager
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import retrofit2.HttpException
import ru.kalinamall.seaca.R
import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.HostDetail
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.kalinamall.seaca.data.DateTimeDisplayMode
import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.LogoffActionRequest
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
import ru.kalinamall.seaca.ui.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.NotificationSoundResolver
import ru.kalinamall.seaca.ui.RingtonePicker
import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes
import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel
import ru.kalinamall.seaca.ui.util.formatEventDetails
import ru.kalinamall.seaca.ui.util.inventoryFieldLines
import ru.kalinamall.seaca.ui.util.isDailyReportType
import ru.kalinamall.seaca.ui.util.reportTextFromDetails
private enum class HostDetailTab(val label: String) {
Info("Информация"),
Events("События"),
}
@Composable
fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var data by remember { mutableStateOf<EventDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
var terminateLoading by remember { mutableStateOf(false) }
var terminateConfirm by remember { mutableStateOf(false) }
var terminateMessage by remember { mutableStateOf<String?>(null) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
LaunchedEffect(id) {
loading = true
try {
data = repo.withAuth { it.getEvent(id) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
val d = data ?: return@DetailFrame
if (!d.rdgAccessPath.isNullOrBlank()) {
Field("Путь RDS", d.rdgAccessPath)
}
if (hasRdgFlapUi(d)) {
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
}
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(
"Тип",
if (isDailyReportType(d.type)) dailyReportTypeLabel(d.type) else d.type,
)
Field("Хост", d.hostname)
Field("Время", SacDateTimes.format(d.occurredAt, dateMode))
Field("Кратко", d.summary)
if (isDailyReportType(d.type)) {
reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) }
?: Field("Отчёт", "Полный текст отчёта недоступен")
} else {
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
private fun EventDetailsSection(type: String, details: JsonElement?, payload: JsonElement?) {
val formatted = formatEventDetails(type, details, payload)
if (formatted?.hasContent == true) {
Text(
"Детали",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp),
)
formatted.multilineText?.let { text ->
MultilineField(formatted.multilineLabel ?: "Сообщение", text)
}
formatted.fields.forEach { (label, value) -> Field(label, value) }
} else if (details != null) {
Field("Детали", details.toPrettyString())
}
}
@Composable
fun ProblemDetailScreen(
repo: SacRepository,
id: Long,
canOperate: Boolean,
onBack: () -> Unit,
onChanged: () -> Unit,
) {
var data by remember { mutableStateOf<ProblemDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var acting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
fun load() {
scope.launch {
loading = true
error = null
try {
data = repo.withAuth { it.getProblem(id) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
LaunchedEffect(id) { load() }
DetailFrame(title = data?.title ?: "Проблема", loading, error, onBack) {
val d = data ?: return@DetailFrame
Field("Статус", d.status)
Field("Severity", d.severity)
Field("Хост", d.hostname ?: "")
Field("Кратко", d.summary)
if (canOperate && d.status in listOf("open", "acknowledged")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (d.status == "open") {
Button(
enabled = !acting,
onClick = {
acting = true
scope.launch {
try {
repo.withAuth { it.ackProblem(id) }
load()
onChanged()
} catch (e: Exception) {
error = e.message
} finally {
acting = false
}
}
},
) { Text("Подтвердить") }
}
Button(
enabled = !acting,
onClick = {
acting = true
scope.launch {
try {
repo.withAuth { it.resolveProblem(id) }
load()
onChanged()
} catch (e: Exception) {
error = e.message
} finally {
acting = false
}
}
},
) { Text("Закрыть") }
}
}
if (d.events.isNotEmpty()) {
Text("Связанные события", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
d.events.forEach { e ->
Text(
"${e.severity} · ${e.title}\n${SacDateTimes.format(e.occurredAt, dateMode)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
fun HostDetailScreen(
repo: SacRepository,
id: Long,
onOpenEvent: (Long) -> Unit,
onBack: () -> Unit,
) {
var data by remember { mutableStateOf<HostDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var selectedTab by remember { mutableIntStateOf(HostDetailTab.Info.ordinal) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
LaunchedEffect(id) {
loading = true
try {
data = repo.withAuth { it.getHost(id) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
Column(
Modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
Row(
Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 16.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = onBack)
Text(
data?.hostname ?: "Хост",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 4.dp),
)
}
error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (loading) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
} else {
val host = data ?: return@Column
TabRow(selectedTabIndex = selectedTab) {
HostDetailTab.entries.forEachIndexed { index, tab ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(tab.label) },
)
}
}
when (HostDetailTab.entries[selectedTab]) {
HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
HostDetailTab.Events -> HostEventsContent(
repo = repo,
hostId = host.id,
onOpenEvent = onOpenEvent,
)
}
}
}
}
@Composable
private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Field("Продукт", host.product)
Field("Версия", host.productVersion ?: "")
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
Field("Агент", host.agentStatus)
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
HostSessionsSection(repo = repo, hostId = host.id)
host.inventory?.let { inv ->
val lines = inventoryFieldLines(inv)
if (lines.isEmpty()) {
Field("Inventory", "")
} else {
Text("Железо и ПО", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
lines.forEach { (label, value) -> Field(label, value) }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HostEventsContent(
repo: SacRepository,
hostId: Long,
onOpenEvent: (Long) -> Unit,
) {
var page by remember { mutableIntStateOf(1) }
var items by remember { mutableStateOf<List<EventSummary>>(emptyList()) }
var total by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) refreshing = true else loading = true
error = null
try {
val res = repo.withAuth {
it.listEvents(
page = page,
pageSize = 30,
hostId = hostId,
includeHidden = true,
)
}
items = res.items
total = res.total
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(page, hostId) { load() }
Column(
Modifier
.fillMaxSize()
.padding(16.dp),
) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
if (items.isEmpty() && loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else {
LazyColumn {
items(items, key = { it.id }) { event ->
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onOpenEvent(event.id) },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp)) {
Text(
"${event.severity} · ${event.title}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
SacDateTimes.format(event.occurredAt, dateMode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = { if (page > 1) page-- }, enabled = page > 1)
Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface)
ForwardArrowButton(onClick = { page++ }, enabled = page * 30 < total)
}
}
}
@Composable
fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
var baseUrl by remember { mutableStateOf<String?>(null) }
var username by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf<String?>(null) }
var deviceId by remember { mutableStateOf(0) }
val context = LocalContext.current
val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND)
val soundSettings by repo.session.notificationSoundSettings.collectAsStateWithLifecycle(
initialValue = NotificationSoundSettings(NotificationSoundSource.SYSTEM, null),
)
val dateTimeMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
val scope = rememberCoroutineScope()
val activity = context as? FragmentActivity
val latestSoundSettings by rememberUpdatedState(soundSettings)
suspend fun applySoundChannels(settings: NotificationSoundSettings) {
NotificationHelper.ensureChannels(
context,
NotificationSoundResolver.resolve(context, settings),
)
}
LaunchedEffect(soundSettings, notificationMode) {
if (notificationMode == NotificationMode.PUSH_SOUND) {
val normalized = repo.session.let {
NotificationSoundResolver.normalizeStoredSettings(context, it)
}
applySoundChannels(normalized)
}
}
fun openRingtonePicker() {
if (activity == null) {
Toast.makeText(context, "Не удалось открыть выбор звука", Toast.LENGTH_SHORT).show()
return
}
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
try {
RingtonePicker.launch(activity, currentUri) { uri ->
if (uri == null) {
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
} else {
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())
}
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, "На устройстве нет выбора звука уведомлений", Toast.LENGTH_LONG).show()
}
}
fun previewSound() {
RingtoneManager.getRingtone(context, NotificationSoundResolver.resolve(context, soundSettings))?.play()
}
LaunchedEffect(Unit) {
baseUrl = repo.session.getBaseUrl()
username = repo.session.getUsername()
role = repo.session.getRole()
deviceId = repo.session.getDeviceId()
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Моё устройство",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Field("Сервер", baseUrl ?: "")
Field("Пользователь", username ?: "")
Field("Роль", role ?: "")
Field("Device ID", deviceId.toString())
Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME)
Text(
stringResource(R.string.notification_mode_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
NotificationMode.entries.forEach { mode ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = notificationMode == mode,
onClick = {
scope.launch { repo.session.setNotificationMode(mode) }
},
)
Text(
mode.label,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
if (notificationMode == NotificationMode.PUSH_SOUND) {
Text(
stringResource(R.string.notification_sound_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
NotificationSoundSource.entries.forEach { source ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = soundSettings.source == source,
onClick = {
when (source) {
NotificationSoundSource.SYSTEM -> {
scope.launch {
repo.session.setNotificationSound(source, null)
applySoundChannels(repo.session.getNotificationSoundSettings())
}
}
NotificationSoundSource.CUSTOM -> {
if (soundSettings.customUri.isNullOrBlank()) {
openRingtonePicker()
} else {
scope.launch {
repo.session.setNotificationSound(source, soundSettings.customUri)
applySoundChannels(repo.session.getNotificationSoundSettings())
}
}
}
}
},
)
Text(
source.label,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Text(
stringResource(
R.string.notification_sound_current,
NotificationSoundResolver.displayName(context, soundSettings),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (soundSettings.source == NotificationSoundSource.CUSTOM) {
Button(onClick = { openRingtonePicker() }) {
Text(stringResource(R.string.notification_sound_pick))
}
}
Button(onClick = { previewSound() }) {
Text(stringResource(R.string.notification_sound_preview))
}
}
}
Text(
stringResource(R.string.date_time_format_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
DateTimeDisplayMode.entries.forEach { mode ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = dateTimeMode == mode,
onClick = {
scope.launch { repo.session.setDateTimeDisplayMode(mode) }
},
)
Text(
mode.label,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Button(
onClick = {
scope.launch {
repo.logout()
onLogout()
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Выйти") }
}
}
}
@Composable
private fun DetailFrame(
title: String,
loading: Boolean,
error: String?,
onBack: () -> Unit,
content: @Composable () -> Unit,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
BackArrowButton(onClick = onBack)
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 4.dp),
)
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else {
content()
}
}
}
@Composable
private fun MultilineField(label: String, value: String) {
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun Field(label: String, value: String) {
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
}
private fun JsonElement.toPrettyString(): String = toString()
@@ -0,0 +1,577 @@
package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.launch
import retrofit2.HttpException
import ru.kalinamall.seaca.data.DateTimeDisplayMode
import ru.kalinamall.seaca.data.DashboardSummary
import ru.kalinamall.seaca.data.EventListResponse
import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.HostSummary
import ru.kalinamall.seaca.data.ProblemSummary
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
import ru.kalinamall.seaca.ui.components.RdgAccessBadge
import ru.kalinamall.seaca.ui.components.RdgFlapBadge
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate
import ru.kalinamall.seaca.ui.util.sessionTerminateLabel
private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов",
"report.daily.rdp" to "Отчёты RDP клиентов",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
var data by remember { mutableStateOf<DashboardSummary?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(false) }
var refreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
val scrollState = rememberScrollState()
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) {
refreshing = true
} else {
loading = true
}
error = null
try {
data = repo.withAuth { it.dashboardSummary() }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(refreshKey) { load() }
LifecycleResumeEffect(refreshKey) {
load(showRefreshIndicator = data != null)
onPauseOrDispose { }
}
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.fillMaxSize(),
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
data?.let { d ->
StatCard("События 24ч", d.eventsLast24h.toString())
StatCard("Проблемы open", d.problemsOpen.toString())
StatCard("Хосты", "${d.hostsTotal} (stale: ${d.hostsStale})")
Text(
"Последние события",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
d.recentEvents.take(8).forEach { e ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!e.rdgAccessPath.isNullOrBlank()) {
RdgAccessBadge(path = e.rdgAccessPath)
}
if (hasRdgFlapUi(e)) {
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
}
Text(
dashboardRecentEventLine(e),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
rdgQwinstaEventId(e)?.let {
RdgQwinstaButton(
controller = qwinsta,
onClick = { qwinsta.openFor(e, scope) },
)
}
}
Text(
SacDateTimes.format(e.occurredAt, dateMode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (data == null && loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
}
RdgQwinstaDialog(qwinsta)
}
private fun dashboardRecentEventLine(event: EventSummary): String {
val body = if (event.type.startsWith("agent.")) {
"${event.hostname}: ${event.title}"
} else {
event.title
}
return "${event.severity}: $body"
}
@Composable
private fun StatCard(label: String, value: String) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var hostname by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
var terminateLoadingId by remember { mutableStateOf<Long?>(null) }
var terminateConfirm by remember { mutableStateOf<EventSummary?>(null) }
var terminateError by remember { mutableStateOf<String?>(null) }
var locallyTerminatedIds by remember { mutableStateOf(setOf<Long>()) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
terminateConfirm?.let { event ->
AlertDialog(
onDismissRequest = { terminateConfirm = null },
title = { Text("Завершить сессию?") },
text = { Text("Завершить сессию пользователя ${sessionTerminateLabel(event)}?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val target = event
terminateConfirm = null
scope.launch {
terminateLoadingId = target.id
terminateError = null
try {
val res = repo.withAuth { it.postEventTerminateSession(target.id) }
if (!res.ok) {
terminateError = res.message
} else {
locallyTerminatedIds = locallyTerminatedIds + target.id
}
} catch (e: Exception) {
terminateError = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminateLoadingId = null
}
}
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { terminateConfirm = null }) { Text("Отмена") }
},
)
}
PagedListScreen(
title = "События",
loadPage = { page, severity, host ->
val res = repo.withAuth {
it.listEvents(
page = page,
pageSize = 30,
severity = severity,
hostname = host,
)
}
res.items to res.total
},
itemKey = EventSummary::id,
itemLabel = { "${it.severity} · ${it.title}" },
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
itemRdgAccessPath = { it.rdgAccessPath },
itemRdgFlap = { hasRdgFlapUi(it) },
itemRdgFlapPairId = { it.rdgFlapPairEventId },
itemQwinsta = { rdgQwinstaEventId(it) != null },
onQwinsta = { qwinsta.openFor(it, scope) },
qwinstaLoading = qwinsta.loading,
itemTerminate = {
eventSupportsSessionTerminate(it) && it.id !in locallyTerminatedIds
},
onTerminate = { terminateConfirm = it },
terminateLoadingId = terminateLoadingId,
onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"),
hostnameFilter = hostname,
headerContent = {
terminateError?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField(
value = hostname,
onValueChange = { hostname = it },
label = { Text("Хост") },
placeholder = { Text("Фильтр по имени хоста") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
},
)
RdgQwinstaDialog(qwinsta)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen(
title = "Проблемы",
loadPage = { page, filter, _ ->
val res = repo.withAuth {
it.listProblems(page = page, pageSize = 30, status = filter ?: "open")
}
res.items to res.total
},
itemKey = ProblemSummary::id,
itemLabel = { "${it.severity} · ${it.title}" },
itemSub = { "${it.status} · ${it.hostname ?: "—"}" },
onOpen = onOpen,
severities = listOf("open", "acknowledged", "resolved"),
filterLabel = "Статус",
)
}
@Composable
fun HostsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen(
title = "Хосты",
loadPage = { page, _, _ ->
val res = repo.withAuth { it.listHosts(page = page, pageSize = 30) }
res.items to res.total
},
itemKey = HostSummary::id,
itemLabel = { it.hostname },
itemSub = { "${it.agentStatus} · ${it.product}" },
onOpen = onOpen,
severities = emptyList(),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var reportType by remember { mutableStateOf("report.daily.ssh") }
var expanded by remember { mutableStateOf(false) }
var page by remember { mutableIntStateOf(1) }
var data by remember { mutableStateOf<EventListResponse?>(null) }
var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) refreshing = true else loading = true
try {
data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(page, reportType) { load() }
val reportTypeLabel = REPORT_TYPE_OPTIONS.firstOrNull { it.first == reportType }?.second ?: reportType
Column(Modifier.fillMaxSize().padding(16.dp)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
readOnly = true,
value = reportTypeLabel,
onValueChange = {},
label = { Text("Тип отчёта") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
REPORT_TYPE_OPTIONS.forEach { (value, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = { reportType = value; expanded = false; page = 1 },
)
}
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
LazyColumn {
items(data?.items.orEmpty(), key = { it.id }) { item ->
ListRow(
title = item.summary,
subtitle = "${item.hostname} · ${SacDateTimes.format(item.occurredAt, dateMode)}",
onClick = { onOpen(item.id) },
)
}
}
}
PagerBar(page, data?.total ?: 0, 30, onPrev = { if (page > 1) page-- }, onNext = { page++ })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> PagedListScreen(
title: String,
loadPage: suspend (Int, String?, String?) -> Pair<List<T>, Int>,
itemKey: (T) -> Long,
itemLabel: (T) -> String,
itemSub: (T) -> String,
onOpen: (Long) -> Unit,
severities: List<String?>,
itemRdgAccessPath: ((T) -> String?)? = null,
itemRdgFlap: ((T) -> Boolean)? = null,
itemRdgFlapPairId: ((T) -> Long?)? = null,
itemQwinsta: ((T) -> Boolean)? = null,
onQwinsta: ((T) -> Unit)? = null,
qwinstaLoading: Boolean = false,
itemTerminate: ((T) -> Boolean)? = null,
onTerminate: ((T) -> Unit)? = null,
terminateLoadingId: Long? = null,
filterLabel: String = "Severity",
hostnameFilter: String = "",
headerContent: @Composable () -> Unit = {},
) {
var page by remember { mutableIntStateOf(1) }
var filter by remember { mutableStateOf(severities.firstOrNull()) }
var items by remember { mutableStateOf<List<T>>(emptyList()) }
var total by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val hostQuery = hostnameFilter.trim().ifBlank { null }
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) refreshing = true else loading = true
try {
val (pageItems, pageTotal) = loadPage(page, filter, hostQuery)
items = pageItems
total = pageTotal
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(hostQuery) {
if (page != 1) page = 1
}
LaunchedEffect(page, filter, hostQuery) { load() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
headerContent()
if (severities.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
severities.forEach { s ->
FilterChip(
selected = filter == s,
onClick = { filter = s; page = 1 },
label = { Text(s ?: "все") },
)
}
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
LazyColumn {
items(items, key = { itemKey(it) }) { 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),
)
}
}
}
PagerBar(page, total, 30, onPrev = { if (page > 1) page-- }, onNext = { if (page * 30 < total) page++ })
}
}
@Composable
private fun ListRow(
title: String,
subtitle: String,
onClick: () -> Unit,
rdgAccessPath: String? = null,
rdgFlap: Boolean = false,
rdgFlapPairId: Long? = null,
showQwinsta: Boolean = false,
onQwinstaClick: (() -> Unit)? = null,
qwinstaLoading: Boolean = false,
showTerminate: Boolean = false,
onTerminateClick: (() -> Unit)? = null,
terminateLoading: Boolean = false,
) {
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(
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,
)
}
}
}
}
@Composable
private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = onPrev, enabled = page > 1)
Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface)
ForwardArrowButton(onClick = onNext, enabled = page * pageSize < total)
}
}
@@ -0,0 +1,44 @@
package ru.kalinamall.seaca.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val SacDark = darkColorScheme(
primary = Color(0xFF3DB8FF),
onPrimary = Color(0xFF002A42),
background = Color(0xFF0F1419),
onBackground = Color(0xFFC5D0DC),
surface = Color(0xFF15202B),
onSurface = Color(0xFFE8EEF4),
surfaceContainer = Color(0xFF1C2834),
surfaceContainerLow = Color(0xFF182330),
surfaceContainerHigh = Color(0xFF243240),
onSurfaceVariant = Color(0xFFB0BEC9),
error = Color(0xFFFF8A8A),
)
private val SacLight = lightColorScheme(
primary = Color(0xFF1A6FA0),
onPrimary = Color(0xFFFFFFFF),
background = Color(0xFFF4F6F8),
onBackground = Color(0xFF1A1F24),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1F24),
surfaceContainer = Color(0xFFEEF2F5),
surfaceContainerLow = Color(0xFFF4F6F8),
surfaceContainerHigh = Color(0xFFE4E9ED),
onSurfaceVariant = Color(0xFF4A5560),
)
@Composable
fun SeacaTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
MaterialTheme(
colorScheme = if (dark) SacDark else SacLight,
content = content,
)
}
@@ -0,0 +1,359 @@
package ru.kalinamall.seaca.ui.util
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class FormattedEventDetails(
val fields: List<Pair<String, String>> = emptyList(),
val multilineLabel: String? = null,
val multilineText: String? = null,
) {
val hasContent: Boolean
get() = fields.isNotEmpty() || !multilineText.isNullOrBlank()
}
fun isDailyReportType(type: String): Boolean =
type == "report.daily.ssh" || type == "report.daily.rdp"
fun dailyReportTypeLabel(type: String): String = when (type) {
"report.daily.ssh" -> "Отчёты ssh клиентов"
"report.daily.rdp" -> "Отчёты RDP клиентов"
else -> type
}
private fun isLoginEventType(type: String): Boolean =
type.startsWith("rdp.login.") ||
type.startsWith("ssh.login.") ||
type.startsWith("agent.login.")
/** Текст ежедневного отчёта из details (не сырой JSON). */
fun reportTextFromDetails(details: JsonElement?): String? {
if (details == null) return null
val obj = details as? JsonObject ?: return null
val body = obj["report_body"]?.jsonPrimitive?.contentOrNull?.trim()
if (!body.isNullOrEmpty()) {
return normalizeReportPlainText(body)
}
val html = obj["report_html"]?.jsonPrimitive?.contentOrNull?.trim()
if (!html.isNullOrEmpty()) {
return normalizeReportPlainText(stripHtmlTags(html))
}
return null
}
fun normalizeReportPlainText(body: String): String =
body
.replace("\r\n", "\n")
.replace("\\n", "\n")
.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
.trim()
private fun stripHtmlTags(html: String): String =
html
.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.trim()
fun formatEventDetails(
type: String,
details: JsonElement?,
payload: JsonElement? = null,
): FormattedEventDetails? {
val obj = details as? JsonObject ?: return null
return when (type) {
"agent.lifecycle" -> formatLifecycleDetails(obj, payload)
"agent.inventory" -> formatInventoryEventDetails(obj)
"rdp.bruteforce.burst" -> formatBruteforceBurstDetails(obj)
else -> when {
isLoginEventType(type) -> formatLoginDetails(obj)
else -> {
val fields = extractStructuredDetailFields(obj)
if (fields.isEmpty()) null else FormattedEventDetails(fields = fields)
}
}
}
}
private fun formatLifecycleDetails(obj: JsonObject, payload: JsonElement?): FormattedEventDetails {
jsonString(obj, "notification_body")?.let { body ->
return FormattedEventDetails(
multilineLabel = "Сообщение",
multilineText = normalizeReportPlainText(body),
)
}
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "lifecycle")?.let { fields += "Событие" to lifecycleLabel(it) }
jsonString(obj, "trigger")?.let { fields += "Причина" to lifecycleTriggerLabel(it) }
sourceProductLine(payload)?.let { fields += it }
return FormattedEventDetails(fields = fields)
}
private fun formatInventoryEventDetails(obj: JsonObject): FormattedEventDetails {
val changes = obj["hardware_changes"]?.jsonArray
if (changes != null && changes.isNotEmpty()) {
val fields = changes.mapIndexedNotNull { index, element ->
val change = element.jsonObject
val field = jsonString(change, "field") ?: return@mapIndexedNotNull null
val old = primitiveDisplay(change["old"]) ?: ""
val new = primitiveDisplay(change["new"]) ?: ""
"Изменение ${index + 1}" to "${hardwareFieldLabel(field)}: $old$new"
}
return FormattedEventDetails(fields = fields)
}
val inventory = obj["inventory"]
if (inventory != null) {
val lines = inventoryFieldLines(inventory)
if (lines.isNotEmpty()) return FormattedEventDetails(fields = lines)
}
val fallback = extractStructuredDetailFields(obj)
return if (fallback.isEmpty()) FormattedEventDetails() else FormattedEventDetails(fields = fallback)
}
private fun formatBruteforceBurstDetails(obj: JsonObject): FormattedEventDetails {
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "tier")?.let { fields += "Уровень" to it }
primitiveDisplay(obj["count"])?.let { fields += "Количество" to it }
jsonString(obj, "bucket_key")?.let { fields += "Ключ" to it }
jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it }
jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it }
if (fields.isEmpty()) {
return FormattedEventDetails(fields = extractStructuredDetailFields(obj))
}
return FormattedEventDetails(fields = fields)
}
private fun formatLoginDetails(obj: JsonObject): FormattedEventDetails {
jsonString(obj, "notification_body")?.let { body ->
return FormattedEventDetails(
multilineLabel = "Сообщение",
multilineText = normalizeReportPlainText(body),
)
}
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it }
jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it }
jsonString(obj, "workstation_name", "computer_name")?.let { fields += "Рабочая станция" to it }
jsonString(obj, "process_name", "process")?.let { fields += "Процесс" to it }
jsonPrimitiveString(obj, "logon_type")?.let { fields += "Тип входа" to logonTypeLabel(it) }
jsonPrimitiveString(obj, "port")?.let { fields += "Порт" to it }
val attempt = jsonPrimitiveString(obj, "attempt_number")
val maxAttempts = jsonPrimitiveString(obj, "max_attempts")
if (attempt != null && maxAttempts != null) {
fields += "Попытка" to "$attempt / $maxAttempts"
}
jsonPrimitiveString(obj, "event_id_windows")?.let { fields += "Event ID Windows" to it }
if (fields.isEmpty()) {
return FormattedEventDetails(fields = extractStructuredDetailFields(obj))
}
return FormattedEventDetails(fields = fields)
}
private fun extractStructuredDetailFields(obj: JsonObject): List<Pair<String, String>> {
val fields = mutableListOf<Pair<String, String>>()
val consumed = mutableSetOf<String>()
fun add(label: String, vararg keys: String) {
jsonString(obj, *keys)?.let {
fields += label to it
keys.forEach { key -> consumed += key }
}
jsonPrimitiveString(obj, *keys)?.let {
fields += label to it
keys.forEach { key -> consumed += key }
}
}
add("Пользователь", "user", "username", "shadower_user", "target_user")
add("IP", "ip_address", "source_ip", "ip", "external_ip", "internal_ip")
add("Рабочая станция", "workstation_name", "computer_name")
add("Процесс", "process_name", "process")
add("Порт", "port")
add("Команда", "command")
add("Share", "share_name")
add("Путь", "share_path")
add("ResourceUri", "resource_uri")
add("Session ID", "session_id", "target_session_id")
add("Event ID Windows", "event_id_windows")
add("Уровень", "tier", "risk_level")
add("Количество", "count")
add("Ключ", "bucket_key")
jsonPrimitiveString(obj, "logon_type")?.let {
fields += "Тип входа" to logonTypeLabel(it)
consumed += "logon_type"
}
obj.forEach { (key, value) ->
if (key in consumed || key in SKIP_DETAIL_KEYS) return@forEach
if (value is JsonPrimitive) {
val text = primitiveDisplay(value) ?: return@forEach
fields += detailKeyLabel(key) to text
}
}
return fields
}
private val SKIP_DETAIL_KEYS = setOf(
"telegram_via",
"generated_by",
"report_body",
"report_html",
"inventory",
"hardware_changes",
"notification_body",
"lifecycle",
"trigger",
)
private fun detailKeyLabel(key: String): String = when (key) {
"user" -> "Пользователь"
"username" -> "Пользователь"
"source_ip" -> "IP"
"ip_address" -> "IP"
"workstation_name" -> "Рабочая станция"
"event_id_windows" -> "Event ID Windows"
"logon_type" -> "Тип входа"
"attempt_number" -> "Попытка"
"max_attempts" -> "Макс. попыток"
else -> key.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
private fun lifecycleLabel(value: String): String = when (value.lowercase()) {
"started" -> "Запущен"
"stopped" -> "Остановлен"
"settings_reloaded" -> "Настройки перечитаны"
else -> value
}
private fun lifecycleTriggerLabel(value: String): String = when (value.lowercase()) {
"boot" -> "Загрузка ОС / планировщик"
"deploy_recycle" -> "Обновление скрипта (recycle)"
"settings_reload" -> "Graceful restart (settings)"
"manual_recycle" -> "Ручной recycle"
"shutdown" -> "Остановка процесса"
else -> value
}
private fun logonTypeLabel(raw: String): String {
val code = raw.trim().toIntOrNull()
val name = when (code) {
2 -> "Interactive"
3 -> "Network"
4 -> "Batch"
5 -> "Service"
7 -> "Unlock"
8 -> "NetworkCleartext"
9 -> "NewCredentials"
10 -> "RemoteInteractive"
11 -> "CachedInteractive"
else -> null
}
return when {
name != null && code != null -> "$name ($code)"
else -> raw
}
}
private fun hardwareFieldLabel(field: String): String = when (field) {
"memory_gb" -> "Память (GB)"
"processor" -> "Процессор"
else -> field
}
private fun sourceProductLine(payload: JsonElement?): Pair<String, String>? {
val source = payload?.jsonObject?.get("source")?.jsonObject ?: return null
val product = jsonString(source, "product")
val version = jsonString(source, "product_version")
val line = listOfNotNull(product, version).joinToString(" ").trim()
return if (line.isEmpty()) null else "Агент" to line
}
private fun jsonString(obj: JsonObject, vararg keys: String): String? {
for (key in keys) {
val value = obj[key]?.jsonPrimitive?.contentOrNull?.trim()
if (!value.isNullOrEmpty()) return value
}
return null
}
private fun jsonPrimitiveString(obj: JsonObject, vararg keys: String): String? {
for (key in keys) {
val el = obj[key] ?: continue
primitiveDisplay(el)?.let { return it }
}
return null
}
private fun primitiveDisplay(el: JsonElement?): String? =
when (el) {
is JsonPrimitive -> el.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
else -> null
}
private fun jsonInt(obj: JsonObject, key: String): Int? =
obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
/** Плоский список полей инвентаризации для карточки хоста / agent.inventory. */
fun inventoryFieldLines(inventory: JsonElement): List<Pair<String, String>> {
val obj = inventory as? JsonObject ?: return emptyList()
val out = mutableListOf<Pair<String, String>>()
jsonString(obj, "computer_name")?.let { out += "Компьютер" to it }
jsonInt(obj, "memory_gb")?.let { out += "Память" to "$it GB" }
obj["windows"]?.jsonObject?.let { w ->
listOf("product_name" to "Продукт", "version" to "Версия", "os_hardware_abstraction_layer" to "HAL")
.forEach { (key, label) ->
jsonString(w, key)?.let { out += label to it }
}
}
obj["motherboard"]?.jsonObject?.let { m ->
val line = listOfNotNull(jsonString(m, "manufacturer"), jsonString(m, "product"))
.joinToString(" ")
.trim()
if (line.isNotEmpty()) out += "Материнская плата" to line
}
obj["processor"]?.jsonArray?.forEachIndexed { i, el ->
val p = el.jsonObject
val name = jsonString(p, "name") ?: return@forEachIndexed
val cores = jsonInt(p, "cores")
val logical = jsonInt(p, "logical_processors")
val suffix = when {
cores != null && logical != null -> "$cores ядер / $logical потоков"
cores != null -> "$cores ядер"
else -> ""
}
out += "Процессор ${i + 1}" to "$name$suffix"
}
obj["disks"]?.jsonArray?.forEachIndexed { i, el ->
val d = el.jsonObject
val name = jsonString(d, "friendly_name") ?: ""
val media = jsonString(d, "media_type") ?: ""
val size = jsonInt(d, "size_gb")?.toString() ?: ""
out += "Диск ${i + 1}" to "$name · $media · ${size} GB"
}
obj["video"]?.jsonArray?.forEachIndexed { i, el ->
jsonString(el.jsonObject, "name")?.let { out += "Видео ${i + 1}" to it }
}
obj["ipv4"]?.jsonArray?.let { arr ->
val ips = arr.mapNotNull { it.jsonPrimitive.contentOrNull?.trim() }.filter { it.isNotEmpty() }
if (ips.isNotEmpty()) out += "IPv4" to ips.joinToString(", ")
}
return out
}
@@ -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,46 @@
package ru.kalinamall.seaca.ui.util
import ru.kalinamall.seaca.data.DateTimeDisplayMode
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
object SacDateTimes {
fun format(raw: String?, mode: DateTimeDisplayMode): String {
if (raw.isNullOrBlank()) return ""
val zoned = parse(raw) ?: return raw.trim()
val local = zoned.withZoneSameInstant(ZoneId.systemDefault())
return when (mode) {
DateTimeDisplayMode.LOCALE -> {
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
.format(local)
}
else -> {
val pattern = mode.pattern ?: DateTimeDisplayMode.SLASH.pattern!!
DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).format(local)
}
}
}
private fun parse(raw: String): ZonedDateTime? {
val trimmed = raw.trim()
val normalised = trimmed.replace(Regex(" ([+-]\\d{2}:\\d{2})$"), "$1")
val candidates = listOf(normalised, trimmed)
for (candidate in candidates) {
runCatching { return OffsetDateTime.parse(candidate).toZonedDateTime() }
runCatching { return ZonedDateTime.parse(candidate) }
runCatching { return Instant.parse(candidate).atZone(ZoneId.systemDefault()) }
runCatching {
return LocalDateTime.parse(candidate, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.atZone(ZoneId.systemDefault())
}
}
return null
}
}
@@ -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,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DB8FF"
android:pathData="M54,30 L78,54 L54,78 L30,54 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#15202B</color>
</resources>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Seaca</string>
<string name="channel_default">SAC уведомления</string>
<string name="channel_high">SAC: важные</string>
<string name="channel_critical">SAC: критические</string>
<string name="channel_default_silent">SAC уведомления (без звука)</string>
<string name="channel_high_silent">SAC: важные (без звука)</string>
<string name="channel_critical_silent">SAC: критические (без звука)</string>
<string name="notification_mode_title">Push-уведомления</string>
<string name="date_time_format_title">Формат даты и времени</string>
<string name="notification_sound_title">Звук уведомлений</string>
<string name="notification_sound_pick">Выбрать звук</string>
<string name="notification_sound_preview">Проверить звук</string>
<string name="notification_sound_current">Сейчас: %1$s</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Seaca" parent="android:Theme.Material.NoActionBar" />
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="notification_sounds" path="." />
</paths>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>