From 477e5ea6ed6998e4670c4aee7fb7955fd0c5194e Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 22 Jun 2026 10:22:21 +1000 Subject: [PATCH] feat: host sessions and event terminate actions (0.5.13) Co-authored-by: Cursor --- app/build.gradle.kts | 4 +- .../java/ru/kalinamall/seaca/data/SacApi.kt | 15 ++ .../ru/kalinamall/seaca/data/SacModels.kt | 37 +++ .../ui/components/HostSessionsSection.kt | 239 ++++++++++++++++++ .../seaca/ui/screens/DetailScreens.kt | 68 ++++- .../seaca/ui/screens/ListScreens.kt | 68 +++++ .../seaca/ui/util/SessionActions.kt | 24 ++ 7 files changed, 451 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f0b3b76..560012c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 26 - versionName = "0.5.12" + versionCode = 27 + versionName = "0.5.13" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt index 3b43364..7e8775a 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt @@ -45,6 +45,21 @@ interface SacApi { @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, diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt index 4d8b68c..ddc6f82 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt @@ -204,6 +204,43 @@ 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 = 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, diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt new file mode 100644 index 0000000..e08d60c --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt @@ -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>(emptyList()) } + var error by remember(hostId) { mutableStateOf(null) } + var message by remember(hostId) { mutableStateOf(null) } + var messageOk by remember(hostId) { mutableStateOf(true) } + var terminatingId by remember(hostId) { mutableStateOf(null) } + var confirmSession by remember(hostId) { mutableStateOf(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 "Завершить") + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index 43ba56f..faf5885 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -19,6 +19,7 @@ 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 @@ -48,6 +49,7 @@ 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 @@ -63,8 +65,12 @@ 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.rdgFlapQwinstaEventId +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 @@ -89,6 +95,9 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { 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(null) } val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -117,6 +126,17 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { ) } } + 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( "Тип", @@ -133,6 +153,49 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } + 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 + } 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) } @@ -316,7 +379,7 @@ fun HostDetailScreen( } } when (HostDetailTab.entries[selectedTab]) { - HostDetailTab.Info -> HostInfoContent(host, dateMode) + HostDetailTab.Info -> HostInfoContent(host, repo, dateMode) HostDetailTab.Events -> HostEventsContent( repo = repo, hostname = host.hostname, @@ -328,7 +391,7 @@ fun HostDetailScreen( } @Composable -private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) { +private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) { Column( Modifier .fillMaxSize() @@ -341,6 +404,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) { 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()) { diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index 9e82beb..e030095 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -21,6 +21,10 @@ 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 @@ -39,6 +43,7 @@ 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 @@ -47,6 +52,7 @@ 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.RdgFlapBadge import ru.kalinamall.seaca.ui.components.RdgQwinstaButton import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog @@ -55,6 +61,8 @@ import ru.kalinamall.seaca.ui.components.ForwardArrowButton import ru.kalinamall.seaca.ui.util.SacDateTimes import ru.kalinamall.seaca.ui.util.hasRdgFlapUi import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId +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 клиентов", @@ -192,10 +200,48 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { var hostname by remember { mutableStateOf("") } val scope = rememberCoroutineScope() val qwinsta = rememberRdgQwinstaController(repo) + var terminateLoadingId by remember { mutableStateOf(null) } + var terminateConfirm by remember { mutableStateOf(null) } + var terminateError by remember { mutableStateOf(null) } 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 + } 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 -> @@ -217,10 +263,16 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { itemQwinsta = { rdgFlapQwinstaEventId(it) != null }, onQwinsta = { qwinsta.openFor(it, scope) }, qwinstaLoading = qwinsta.loading, + itemTerminate = { eventSupportsSessionTerminate(it) }, + 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 }, @@ -359,6 +411,9 @@ private fun PagedListScreen( 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 = {}, @@ -426,6 +481,9 @@ private fun PagedListScreen( 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), ) } } @@ -444,6 +502,9 @@ private fun ListRow( showQwinsta: Boolean = false, onQwinstaClick: (() -> Unit)? = null, qwinstaLoading: Boolean = false, + showTerminate: Boolean = false, + onTerminateClick: (() -> Unit)? = null, + terminateLoading: Boolean = false, ) { Card( Modifier @@ -473,6 +534,13 @@ private fun ListRow( onClick = onQwinstaClick, ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") } } + if (showTerminate && onTerminateClick != null) { + EventSessionTerminateButton( + enabled = !terminateLoading, + loading = terminateLoading, + onClick = onTerminateClick, + ) + } } } } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt new file mode 100644 index 0000000..5d765c5 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt @@ -0,0 +1,24 @@ +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 + +fun eventSupportsSessionTerminate(event: EventDetail): Boolean = + event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES + +fun sessionTerminateLabel(event: EventSummary): String = + event.actorUser?.takeIf { it.isNotBlank() } ?: event.title + +fun sessionTerminateLabel(event: EventDetail): String = + event.actorUser?.takeIf { it.isNotBlank() } ?: event.title