feat: host sessions and event terminate actions (0.5.13)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-22 10:22:21 +10:00
parent 6592f027a1
commit 477e5ea6ed
7 changed files with 451 additions and 4 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca" applicationId = "ru.kalinamall.seaca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 26 versionCode = 27
versionName = "0.5.12" versionName = "0.5.13"
} }
buildTypes { buildTypes {
@@ -45,6 +45,21 @@ interface SacApi {
@Body body: LogoffActionRequest, @Body body: LogoffActionRequest,
): AgentCommandResponse ): AgentCommandResponse
@POST("api/v1/events/{id}/actions/terminate-session")
suspend fun postEventTerminateSession(
@Path("id") id: Long,
@Body body: EventSessionTerminateRequest = EventSessionTerminateRequest(),
): SessionActionResponse
@POST("api/v1/hosts/{id}/actions/sessions/list")
suspend fun postHostSessionsList(@Path("id") id: Long): HostSessionsResponse
@POST("api/v1/hosts/{id}/actions/sessions/terminate")
suspend fun postHostSessionTerminate(
@Path("id") id: Long,
@Body body: HostSessionTerminateRequest,
): SessionActionResponse
@GET("api/v1/problems") @GET("api/v1/problems")
suspend fun listProblems( suspend fun listProblems(
@Query("page") page: Int = 1, @Query("page") page: Int = 1,
@@ -204,6 +204,43 @@ data class LogoffActionRequest(
@SerialName("session_id") val sessionId: Int, @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 @Serializable
data class AgentCommandResponse( data class AgentCommandResponse(
@SerialName("command_uuid") val commandUuid: String, @SerialName("command_uuid") val commandUuid: String,
@@ -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 "Завершить")
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -48,6 +49,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import retrofit2.HttpException
import ru.kalinamall.seaca.R import ru.kalinamall.seaca.R
import ru.kalinamall.seaca.data.EventSummary import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.EventDetail import ru.kalinamall.seaca.data.EventDetail
@@ -63,8 +65,12 @@ import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController 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.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId 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.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver import ru.kalinamall.seaca.push.NotificationSoundResolver
import ru.kalinamall.seaca.ui.RingtonePicker import ru.kalinamall.seaca.ui.RingtonePicker
@@ -89,6 +95,9 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo) val qwinsta = rememberRdgQwinstaController(repo)
var terminateLoading by remember { mutableStateOf(false) }
var terminateConfirm by remember { mutableStateOf(false) }
var terminateMessage by remember { mutableStateOf<String?>(null) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH, initialValue = DateTimeDisplayMode.SLASH,
) )
@@ -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("Severity", d.severity)
Field( 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) RdgQwinstaDialog(qwinsta)
} }
@@ -316,7 +379,7 @@ fun HostDetailScreen(
} }
} }
when (HostDetailTab.entries[selectedTab]) { when (HostDetailTab.entries[selectedTab]) {
HostDetailTab.Info -> HostInfoContent(host, dateMode) HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
HostDetailTab.Events -> HostEventsContent( HostDetailTab.Events -> HostEventsContent(
repo = repo, repo = repo,
hostname = host.hostname, hostname = host.hostname,
@@ -328,7 +391,7 @@ fun HostDetailScreen(
} }
@Composable @Composable
private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) { private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -341,6 +404,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim()) Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
Field("Агент", host.agentStatus) Field("Агент", host.agentStatus)
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode)) Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
HostSessionsSection(repo = repo, hostId = host.id)
host.inventory?.let { inv -> host.inventory?.let { inv ->
val lines = inventoryFieldLines(inv) val lines = inventoryFieldLines(inv)
if (lines.isEmpty()) { if (lines.isEmpty()) {
@@ -21,6 +21,10 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -39,6 +43,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException
import ru.kalinamall.seaca.data.DateTimeDisplayMode import ru.kalinamall.seaca.data.DateTimeDisplayMode
import ru.kalinamall.seaca.data.DashboardSummary import ru.kalinamall.seaca.data.DashboardSummary
import ru.kalinamall.seaca.data.EventListResponse import ru.kalinamall.seaca.data.EventListResponse
@@ -47,6 +52,7 @@ import ru.kalinamall.seaca.data.HostSummary
import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.ProblemSummary
import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
import ru.kalinamall.seaca.ui.components.RdgFlapBadge import ru.kalinamall.seaca.ui.components.RdgFlapBadge
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog 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.SacDateTimes
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId 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( private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов", "report.daily.ssh" to "Отчёты ssh клиентов",
@@ -192,10 +200,48 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var hostname by remember { mutableStateOf("") } var hostname by remember { mutableStateOf("") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo) 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) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH, initialValue = DateTimeDisplayMode.SLASH,
) )
terminateConfirm?.let { event ->
AlertDialog(
onDismissRequest = { terminateConfirm = null },
title = { Text("Завершить сессию?") },
text = { Text("Завершить сессию пользователя ${sessionTerminateLabel(event)}?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val target = event
terminateConfirm = null
scope.launch {
terminateLoadingId = target.id
terminateError = null
try {
val res = repo.withAuth { it.postEventTerminateSession(target.id) }
if (!res.ok) terminateError = res.message
} catch (e: Exception) {
terminateError = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminateLoadingId = null
}
}
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { terminateConfirm = null }) { Text("Отмена") }
},
)
}
PagedListScreen( PagedListScreen(
title = "События", title = "События",
loadPage = { page, severity, host -> loadPage = { page, severity, host ->
@@ -217,10 +263,16 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
itemQwinsta = { rdgFlapQwinstaEventId(it) != null }, itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
onQwinsta = { qwinsta.openFor(it, scope) }, onQwinsta = { qwinsta.openFor(it, scope) },
qwinstaLoading = qwinsta.loading, qwinstaLoading = qwinsta.loading,
itemTerminate = { eventSupportsSessionTerminate(it) },
onTerminate = { terminateConfirm = it },
terminateLoadingId = terminateLoadingId,
onOpen = onOpen, onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"), severities = listOf(null, "info", "warning", "high", "critical"),
hostnameFilter = hostname, hostnameFilter = hostname,
headerContent = { headerContent = {
terminateError?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField( OutlinedTextField(
value = hostname, value = hostname,
onValueChange = { hostname = it }, onValueChange = { hostname = it },
@@ -359,6 +411,9 @@ private fun <T> PagedListScreen(
itemQwinsta: ((T) -> Boolean)? = null, itemQwinsta: ((T) -> Boolean)? = null,
onQwinsta: ((T) -> Unit)? = null, onQwinsta: ((T) -> Unit)? = null,
qwinstaLoading: Boolean = false, qwinstaLoading: Boolean = false,
itemTerminate: ((T) -> Boolean)? = null,
onTerminate: ((T) -> Unit)? = null,
terminateLoadingId: Long? = null,
filterLabel: String = "Severity", filterLabel: String = "Severity",
hostnameFilter: String = "", hostnameFilter: String = "",
headerContent: @Composable () -> Unit = {}, headerContent: @Composable () -> Unit = {},
@@ -426,6 +481,9 @@ private fun <T> PagedListScreen(
showQwinsta = itemQwinsta?.invoke(item) == true, showQwinsta = itemQwinsta?.invoke(item) == true,
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } }, onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
qwinstaLoading = qwinstaLoading, 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, showQwinsta: Boolean = false,
onQwinstaClick: (() -> Unit)? = null, onQwinstaClick: (() -> Unit)? = null,
qwinstaLoading: Boolean = false, qwinstaLoading: Boolean = false,
showTerminate: Boolean = false,
onTerminateClick: (() -> Unit)? = null,
terminateLoading: Boolean = false,
) { ) {
Card( Card(
Modifier Modifier
@@ -473,6 +534,13 @@ private fun ListRow(
onClick = onQwinstaClick, onClick = onQwinstaClick,
) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") } ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") }
} }
if (showTerminate && onTerminateClick != null) {
EventSessionTerminateButton(
enabled = !terminateLoading,
loading = terminateLoading,
onClick = onTerminateClick,
)
}
} }
} }
} }
@@ -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