fix: visible logoff buttons in RDG qwinsta dialog (0.5.10)
Scrollable session list with red logoff actions, manual session ID fallback, and qwinsta from events/dashboard.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
**Версия:** `0.5.9` (versionCode 23)
|
**Версия:** `0.5.10` (versionCode 24)
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "ru.kalinamall.seaca"
|
applicationId = "ru.kalinamall.seaca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 23
|
versionCode = 24
|
||||||
versionName = "0.5.9"
|
versionName = "0.5.10"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
package ru.kalinamall.seaca.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import ru.kalinamall.seaca.data.EventDetail
|
||||||
|
import ru.kalinamall.seaca.data.EventSummary
|
||||||
|
import ru.kalinamall.seaca.data.LogoffActionRequest
|
||||||
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
|
import ru.kalinamall.seaca.ui.util.QwinstaSessionRow
|
||||||
|
import ru.kalinamall.seaca.ui.util.agentCommandError
|
||||||
|
import ru.kalinamall.seaca.ui.util.parseQwinstaSessions
|
||||||
|
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
||||||
|
|
||||||
|
class RdgQwinstaController(private val repo: SacRepository) {
|
||||||
|
var open by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var loading by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var error by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var meta by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var stdout by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var sessions by mutableStateOf<List<QwinstaSessionRow>>(emptyList())
|
||||||
|
private set
|
||||||
|
var logoffSessionId by mutableStateOf<Int?>(null)
|
||||||
|
private set
|
||||||
|
var targetEventId by mutableStateOf<Long?>(null)
|
||||||
|
private set
|
||||||
|
private var actorUser: String? = null
|
||||||
|
|
||||||
|
fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
|
||||||
|
val targetId = rdgFlapQwinstaEventId(event) ?: return
|
||||||
|
targetEventId = targetId
|
||||||
|
actorUser = event.actorUser
|
||||||
|
open = true
|
||||||
|
runQwinsta(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
|
||||||
|
val targetId = rdgFlapQwinstaEventId(event) ?: return
|
||||||
|
targetEventId = targetId
|
||||||
|
actorUser = event.actorUser
|
||||||
|
open = true
|
||||||
|
runQwinsta(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss() {
|
||||||
|
if (!loading && logoffSessionId == null) {
|
||||||
|
open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runQwinsta(scope: kotlinx.coroutines.CoroutineScope) {
|
||||||
|
val targetId = targetEventId ?: return
|
||||||
|
scope.launch {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
stdout = null
|
||||||
|
sessions = emptyList()
|
||||||
|
try {
|
||||||
|
val cmd = repo.withAuth { it.postEventQwinsta(targetId) }
|
||||||
|
meta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target)
|
||||||
|
.joinToString(" · ")
|
||||||
|
.ifBlank { null }
|
||||||
|
val cmdError = agentCommandError(cmd)
|
||||||
|
if (cmdError != null) {
|
||||||
|
error = cmdError
|
||||||
|
} else {
|
||||||
|
val out = cmd.resultStdout.orEmpty()
|
||||||
|
stdout = out.ifBlank { null }
|
||||||
|
sessions = parseQwinstaSessions(out, actorUser)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = if (e is HttpException) SacRepository.parseError(e) else e.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logoff(sessionId: Int, scope: kotlinx.coroutines.CoroutineScope, onDone: () -> Unit = {}) {
|
||||||
|
val targetId = targetEventId ?: return
|
||||||
|
scope.launch {
|
||||||
|
logoffSessionId = sessionId
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
val cmd = repo.withAuth {
|
||||||
|
it.postEventLogoff(targetId, LogoffActionRequest(sessionId))
|
||||||
|
}
|
||||||
|
val cmdError = agentCommandError(cmd)
|
||||||
|
if (cmdError != null) {
|
||||||
|
error = cmdError
|
||||||
|
} else {
|
||||||
|
runQwinsta(scope)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = if (e is HttpException) SacRepository.parseError(e) else e.message
|
||||||
|
} finally {
|
||||||
|
logoffSessionId = null
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberRdgQwinstaController(repo: SacRepository): RdgQwinstaController =
|
||||||
|
remember(repo) { RdgQwinstaController(repo) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RdgQwinstaDialog(
|
||||||
|
controller: RdgQwinstaController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (!controller.open) return
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var confirmSessionId by remember { mutableIntStateOf(-1) }
|
||||||
|
var manualSessionId by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
if (confirmSessionId >= 0) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmSessionId = -1 },
|
||||||
|
title = { Text("Завершить сеанс?") },
|
||||||
|
text = { Text("Отправить logoff для сеанса ID $confirmSessionId на клиентском ПК?") },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
val id = confirmSessionId
|
||||||
|
confirmSessionId = -1
|
||||||
|
controller.logoff(id, scope)
|
||||||
|
},
|
||||||
|
) { Text("logoff") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmSessionId = -1 }) { Text("Отмена") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { controller.dismiss() },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.heightIn(max = 520.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text("qwinsta / logoff", style = MaterialTheme.typography.titleLarge)
|
||||||
|
controller.meta?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
controller.loading -> {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text("Ожидание ответа…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.error != null -> {
|
||||||
|
Text(controller.error!!, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.sessions.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Сеансы — нажмите logoff, чтобы оборвать зависшую сессию:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
controller.sessions.forEach { s ->
|
||||||
|
SessionLogoffRow(
|
||||||
|
session = s,
|
||||||
|
logoffEnabled = !controller.loading && controller.logoffSessionId == null,
|
||||||
|
logoffInProgress = controller.logoffSessionId == s.id,
|
||||||
|
onLogoff = { confirmSessionId = s.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (!controller.loading && controller.stdout != null) {
|
||||||
|
Text(
|
||||||
|
"Сеансы не распознаны автоматически. Введите ID из вывода ниже:",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manualSessionId,
|
||||||
|
onValueChange = { manualSessionId = it.filter { ch -> ch.isDigit() } },
|
||||||
|
label = { Text("ID сеанса") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
enabled = manualSessionId.isNotBlank() && !controller.loading && controller.logoffSessionId == null,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
manualSessionId.toIntOrNull()?.let { confirmSessionId = it }
|
||||||
|
},
|
||||||
|
) { Text("logoff") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.stdout?.let { raw ->
|
||||||
|
Text("Вывод qwinsta", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(raw, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (controller.stdout == null && !controller.loading && controller.error == null) {
|
||||||
|
Text("Нет вывода", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
enabled = !controller.loading && controller.logoffSessionId == null,
|
||||||
|
onClick = { controller.dismiss() },
|
||||||
|
) { Text("Закрыть") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SessionLogoffRow(
|
||||||
|
session: QwinstaSessionRow,
|
||||||
|
logoffEnabled: Boolean,
|
||||||
|
logoffInProgress: Boolean,
|
||||||
|
onLogoff: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(session.sessionName, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
"${session.userName} · ID ${session.id} · ${session.state}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (logoffInProgress) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp))
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
enabled = logoffEnabled,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onClick = onLogoff,
|
||||||
|
) { Text("logoff") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RdgQwinstaButton(
|
||||||
|
controller: RdgQwinstaController,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = enabled && !controller.loading,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(if (controller.loading) "qwinsta…" else "qwinsta")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,10 +60,10 @@ import ru.kalinamall.seaca.data.NotificationSoundSource
|
|||||||
import ru.kalinamall.seaca.data.ProblemDetail
|
import ru.kalinamall.seaca.data.ProblemDetail
|
||||||
import ru.kalinamall.seaca.data.LogoffActionRequest
|
import ru.kalinamall.seaca.data.LogoffActionRequest
|
||||||
import ru.kalinamall.seaca.data.SacRepository
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
import ru.kalinamall.seaca.ui.util.QwinstaSessionRow
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
||||||
import ru.kalinamall.seaca.ui.util.agentCommandError
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
||||||
|
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
|
||||||
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
||||||
import ru.kalinamall.seaca.ui.util.parseQwinstaSessions
|
|
||||||
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
||||||
import ru.kalinamall.seaca.push.NotificationHelper
|
import ru.kalinamall.seaca.push.NotificationHelper
|
||||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||||
@@ -87,14 +87,8 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
var data by remember { mutableStateOf<EventDetail?>(null) }
|
var data by remember { mutableStateOf<EventDetail?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var qwinstaOpen by remember { mutableStateOf(false) }
|
|
||||||
var qwinstaLoading by remember { mutableStateOf(false) }
|
|
||||||
var qwinstaError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var qwinstaStdout by remember { mutableStateOf<String?>(null) }
|
|
||||||
var qwinstaSessions by remember { mutableStateOf<List<QwinstaSessionRow>>(emptyList()) }
|
|
||||||
var qwinstaMeta by remember { mutableStateOf<String?>(null) }
|
|
||||||
var logoffSessionId by remember { mutableStateOf<Int?>(null) }
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val qwinsta = rememberRdgQwinstaController(repo)
|
||||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
initialValue = DateTimeDisplayMode.SLASH,
|
||||||
)
|
)
|
||||||
@@ -110,47 +104,17 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runQwinsta(targetEventId: Long, actorUser: String?) {
|
|
||||||
scope.launch {
|
|
||||||
qwinstaLoading = true
|
|
||||||
qwinstaError = null
|
|
||||||
qwinstaStdout = null
|
|
||||||
qwinstaSessions = emptyList()
|
|
||||||
try {
|
|
||||||
val cmd = repo.withAuth { it.postEventQwinsta(targetEventId) }
|
|
||||||
qwinstaMeta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target)
|
|
||||||
.joinToString(" · ")
|
|
||||||
.ifBlank { null }
|
|
||||||
val cmdError = agentCommandError(cmd)
|
|
||||||
if (cmdError != null) {
|
|
||||||
qwinstaError = cmdError
|
|
||||||
} else {
|
|
||||||
val out = cmd.resultStdout.orEmpty()
|
|
||||||
qwinstaStdout = out.ifBlank { null }
|
|
||||||
qwinstaSessions = parseQwinstaSessions(out, actorUser)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
qwinstaError = if (e is retrofit2.HttpException) SacRepository.parseError(e) else e.message
|
|
||||||
} finally {
|
|
||||||
qwinstaLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
||||||
val d = data ?: return@DetailFrame
|
val d = data ?: return@DetailFrame
|
||||||
if (hasRdgFlapUi(d)) {
|
if (hasRdgFlapUi(d)) {
|
||||||
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
|
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
|
||||||
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
|
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
|
||||||
rdgFlapQwinstaEventId(d)?.let { targetId ->
|
rdgFlapQwinstaEventId(d)?.let {
|
||||||
Button(
|
RdgQwinstaButton(
|
||||||
enabled = !qwinstaLoading,
|
controller = qwinsta,
|
||||||
onClick = {
|
onClick = { qwinsta.openFor(d, scope) },
|
||||||
qwinstaOpen = true
|
|
||||||
runQwinsta(targetId, d.actorUser)
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Field("Severity", d.severity)
|
Field("Severity", d.severity)
|
||||||
@@ -169,74 +133,7 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qwinstaOpen) {
|
RdgQwinstaDialog(qwinsta)
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { if (!qwinstaLoading && logoffSessionId == null) qwinstaOpen = false },
|
|
||||||
title = { Text("qwinsta") },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
qwinstaMeta?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
|
||||||
when {
|
|
||||||
qwinstaLoading -> CircularProgressIndicator()
|
|
||||||
qwinstaError != null -> Text(qwinstaError!!, color = MaterialTheme.colorScheme.error)
|
|
||||||
qwinstaStdout != null -> Text(
|
|
||||||
qwinstaStdout!!,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
else -> Text("Нет вывода")
|
|
||||||
}
|
|
||||||
qwinstaSessions.forEach { s ->
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"${s.sessionName} ${s.userName} ${s.id} ${s.state}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
OutlinedButton(
|
|
||||||
enabled = logoffSessionId == null && !qwinstaLoading,
|
|
||||||
onClick = {
|
|
||||||
val targetId = rdgFlapQwinstaEventId(data ?: return@OutlinedButton) ?: return@OutlinedButton
|
|
||||||
scope.launch {
|
|
||||||
logoffSessionId = s.id
|
|
||||||
qwinstaError = null
|
|
||||||
try {
|
|
||||||
val cmd = repo.withAuth {
|
|
||||||
it.postEventLogoff(targetId, LogoffActionRequest(s.id))
|
|
||||||
}
|
|
||||||
val cmdError = agentCommandError(cmd)
|
|
||||||
if (cmdError != null) {
|
|
||||||
qwinstaError = cmdError
|
|
||||||
} else {
|
|
||||||
runQwinsta(targetId, data?.actorUser)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
qwinstaError = if (e is retrofit2.HttpException) {
|
|
||||||
SacRepository.parseError(e)
|
|
||||||
} else {
|
|
||||||
e.message
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
logoffSessionId = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text("logoff") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
enabled = !qwinstaLoading && logoffSessionId == null,
|
|
||||||
onClick = { qwinstaOpen = false },
|
|
||||||
) { Text("Закрыть") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MenuAnchorType
|
import androidx.compose.material3.MenuAnchorType
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
@@ -47,9 +48,13 @@ import ru.kalinamall.seaca.data.ProblemSummary
|
|||||||
import ru.kalinamall.seaca.data.SacRepository
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
import ru.kalinamall.seaca.ui.components.BackArrowButton
|
import ru.kalinamall.seaca.ui.components.BackArrowButton
|
||||||
import ru.kalinamall.seaca.ui.components.RdgFlapBadge
|
import ru.kalinamall.seaca.ui.components.RdgFlapBadge
|
||||||
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
|
||||||
|
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
|
||||||
|
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
|
||||||
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
|
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
|
||||||
import ru.kalinamall.seaca.ui.util.SacDateTimes
|
import ru.kalinamall.seaca.ui.util.SacDateTimes
|
||||||
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
|
||||||
|
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
||||||
|
|
||||||
private val REPORT_TYPE_OPTIONS = listOf(
|
private val REPORT_TYPE_OPTIONS = listOf(
|
||||||
"report.daily.ssh" to "Отчёты ssh клиентов",
|
"report.daily.ssh" to "Отчёты ssh клиентов",
|
||||||
@@ -64,6 +69,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
var loading by remember { mutableStateOf(false) }
|
var loading by remember { mutableStateOf(false) }
|
||||||
var refreshing by remember { mutableStateOf(false) }
|
var refreshing by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val qwinsta = rememberRdgQwinstaController(repo)
|
||||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
initialValue = DateTimeDisplayMode.SLASH,
|
||||||
)
|
)
|
||||||
@@ -132,6 +138,12 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
if (rdgFlapQwinstaEventId(e) != null) {
|
||||||
|
RdgQwinstaButton(
|
||||||
|
controller = qwinsta,
|
||||||
|
onClick = { qwinsta.openFor(e, scope) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
SacDateTimes.format(e.occurredAt, dateMode),
|
SacDateTimes.format(e.occurredAt, dateMode),
|
||||||
@@ -146,6 +158,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RdgQwinstaDialog(qwinsta)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dashboardRecentEventLine(event: EventSummary): String {
|
private fun dashboardRecentEventLine(event: EventSummary): String {
|
||||||
@@ -177,6 +190,8 @@ private fun StatCard(label: String, value: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
||||||
var hostname by remember { mutableStateOf("") }
|
var hostname by remember { mutableStateOf("") }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val qwinsta = rememberRdgQwinstaController(repo)
|
||||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||||
initialValue = DateTimeDisplayMode.SLASH,
|
initialValue = DateTimeDisplayMode.SLASH,
|
||||||
)
|
)
|
||||||
@@ -199,6 +214,9 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
|||||||
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
||||||
itemRdgFlap = { hasRdgFlapUi(it) },
|
itemRdgFlap = { hasRdgFlapUi(it) },
|
||||||
itemRdgFlapPairId = { it.rdgFlapPairEventId },
|
itemRdgFlapPairId = { it.rdgFlapPairEventId },
|
||||||
|
itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
|
||||||
|
onQwinsta = { qwinsta.openFor(it, scope) },
|
||||||
|
qwinstaLoading = qwinsta.loading,
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
severities = listOf(null, "info", "warning", "high", "critical"),
|
severities = listOf(null, "info", "warning", "high", "critical"),
|
||||||
hostnameFilter = hostname,
|
hostnameFilter = hostname,
|
||||||
@@ -213,6 +231,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
RdgQwinstaDialog(qwinsta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -337,6 +356,9 @@ private fun <T> PagedListScreen(
|
|||||||
severities: List<String?>,
|
severities: List<String?>,
|
||||||
itemRdgFlap: ((T) -> Boolean)? = null,
|
itemRdgFlap: ((T) -> Boolean)? = null,
|
||||||
itemRdgFlapPairId: ((T) -> Long?)? = null,
|
itemRdgFlapPairId: ((T) -> Long?)? = null,
|
||||||
|
itemQwinsta: ((T) -> Boolean)? = null,
|
||||||
|
onQwinsta: ((T) -> Unit)? = null,
|
||||||
|
qwinstaLoading: Boolean = false,
|
||||||
filterLabel: String = "Severity",
|
filterLabel: String = "Severity",
|
||||||
hostnameFilter: String = "",
|
hostnameFilter: String = "",
|
||||||
headerContent: @Composable () -> Unit = {},
|
headerContent: @Composable () -> Unit = {},
|
||||||
@@ -401,6 +423,9 @@ private fun <T> PagedListScreen(
|
|||||||
onClick = { onOpen(itemKey(item)) },
|
onClick = { onOpen(itemKey(item)) },
|
||||||
rdgFlap = itemRdgFlap?.invoke(item) == true,
|
rdgFlap = itemRdgFlap?.invoke(item) == true,
|
||||||
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
|
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
|
||||||
|
showQwinsta = itemQwinsta?.invoke(item) == true,
|
||||||
|
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
|
||||||
|
qwinstaLoading = qwinstaLoading,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,23 +441,38 @@ private fun ListRow(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
rdgFlap: Boolean = false,
|
rdgFlap: Boolean = false,
|
||||||
rdgFlapPairId: Long? = null,
|
rdgFlapPairId: Long? = null,
|
||||||
|
showQwinsta: Boolean = false,
|
||||||
|
onQwinstaClick: (() -> Unit)? = null,
|
||||||
|
qwinstaLoading: Boolean = false,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp),
|
||||||
.clickable(onClick = onClick),
|
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
if (rdgFlap) {
|
Column(
|
||||||
RdgFlapBadge(pairEventId = rdgFlapPairId)
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (rdgFlap) {
|
||||||
|
RdgFlapBadge(pairEventId = rdgFlapPairId)
|
||||||
|
}
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
if (showQwinsta && onQwinstaClick != null) {
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = !qwinstaLoading,
|
||||||
|
onClick = onQwinstaClick,
|
||||||
|
) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") }
|
||||||
}
|
}
|
||||||
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessio
|
|||||||
}
|
}
|
||||||
rows.add(
|
rows.add(
|
||||||
QwinstaSessionRow(
|
QwinstaSessionRow(
|
||||||
sessionName = parts[0],
|
sessionName = parts[0].removePrefix(">").trim(),
|
||||||
userName = userName,
|
userName = userName,
|
||||||
id = id,
|
id = id,
|
||||||
state = parts.drop(3).joinToString(" "),
|
state = parts.drop(3).joinToString(" "),
|
||||||
@@ -52,7 +52,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessio
|
|||||||
val id = parts[2].toIntOrNull() ?: continue
|
val id = parts[2].toIntOrNull() ?: continue
|
||||||
rows.add(
|
rows.add(
|
||||||
QwinstaSessionRow(
|
QwinstaSessionRow(
|
||||||
sessionName = parts[0],
|
sessionName = parts[0].removePrefix(">").trim(),
|
||||||
userName = parts[1],
|
userName = parts[1],
|
||||||
id = id,
|
id = id,
|
||||||
state = parts.drop(3).joinToString(" "),
|
state = parts.drop(3).joinToString(" "),
|
||||||
|
|||||||
Reference in New Issue
Block a user