feat: host sessions and event terminate actions (0.5.13)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user