diff --git a/README.md b/README.md index 08328ff..e1558a8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center). -**Версия:** `0.5.9` (versionCode 23) +**Версия:** `0.5.10` (versionCode 24) ## Возможности diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36fa822..471795c 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 = 23 - versionName = "0.5.9" + versionCode = 24 + versionName = "0.5.10" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt new file mode 100644 index 0000000..7023e6c --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt @@ -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(null) + private set + var meta by mutableStateOf(null) + private set + var stdout by mutableStateOf(null) + private set + var sessions by mutableStateOf>(emptyList()) + private set + var logoffSessionId by mutableStateOf(null) + private set + var targetEventId by mutableStateOf(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") + } +} 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 b92d161..03e9454 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 @@ -60,10 +60,10 @@ import ru.kalinamall.seaca.data.NotificationSoundSource import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.LogoffActionRequest import ru.kalinamall.seaca.data.SacRepository -import ru.kalinamall.seaca.ui.util.QwinstaSessionRow -import ru.kalinamall.seaca.ui.util.agentCommandError +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.util.hasRdgFlapUi -import ru.kalinamall.seaca.ui.util.parseQwinstaSessions import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId import ru.kalinamall.seaca.push.NotificationHelper import ru.kalinamall.seaca.push.NotificationSoundResolver @@ -87,14 +87,8 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { var data by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } - var qwinstaOpen by remember { mutableStateOf(false) } - var qwinstaLoading by remember { mutableStateOf(false) } - var qwinstaError by remember { mutableStateOf(null) } - var qwinstaStdout by remember { mutableStateOf(null) } - var qwinstaSessions by remember { mutableStateOf>(emptyList()) } - var qwinstaMeta by remember { mutableStateOf(null) } - var logoffSessionId by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( 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) { val d = data ?: return@DetailFrame if (hasRdgFlapUi(d)) { Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя") d.rdgFlapPairEventId?.let { Field("Пара", "#$it") } - rdgFlapQwinstaEventId(d)?.let { targetId -> - Button( - enabled = !qwinstaLoading, - onClick = { - qwinstaOpen = true - runQwinsta(targetId, d.actorUser) - }, + rdgFlapQwinstaEventId(d)?.let { + RdgQwinstaButton( + controller = qwinsta, + onClick = { qwinsta.openFor(d, scope) }, modifier = Modifier.padding(vertical = 4.dp), - ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") } + ) } } Field("Severity", d.severity) @@ -169,74 +133,7 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } - if (qwinstaOpen) { - 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("Закрыть") } - }, - ) - } + RdgQwinstaDialog(qwinsta) } @Composable 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 bf4948d..9e82beb 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,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text 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.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.RdgFlapBadge +import ru.kalinamall.seaca.ui.components.RdgQwinstaButton +import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog +import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController import ru.kalinamall.seaca.ui.components.ForwardArrowButton import ru.kalinamall.seaca.ui.util.SacDateTimes import ru.kalinamall.seaca.ui.util.hasRdgFlapUi +import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId private val REPORT_TYPE_OPTIONS = listOf( "report.daily.ssh" to "Отчёты ssh клиентов", @@ -64,6 +69,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { var loading by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -132,6 +138,12 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) + if (rdgFlapQwinstaEventId(e) != null) { + RdgQwinstaButton( + controller = qwinsta, + onClick = { qwinsta.openFor(e, scope) }, + ) + } } Text( SacDateTimes.format(e.occurredAt, dateMode), @@ -146,6 +158,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { } } } + RdgQwinstaDialog(qwinsta) } private fun dashboardRecentEventLine(event: EventSummary): String { @@ -177,6 +190,8 @@ private fun StatCard(label: String, value: String) { @Composable fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { var hostname by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -199,6 +214,9 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" }, itemRdgFlap = { hasRdgFlapUi(it) }, itemRdgFlapPairId = { it.rdgFlapPairEventId }, + itemQwinsta = { rdgFlapQwinstaEventId(it) != null }, + onQwinsta = { qwinsta.openFor(it, scope) }, + qwinstaLoading = qwinsta.loading, onOpen = onOpen, severities = listOf(null, "info", "warning", "high", "critical"), hostnameFilter = hostname, @@ -213,6 +231,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { ) }, ) + RdgQwinstaDialog(qwinsta) } @OptIn(ExperimentalMaterial3Api::class) @@ -337,6 +356,9 @@ private fun PagedListScreen( severities: List, itemRdgFlap: ((T) -> Boolean)? = null, itemRdgFlapPairId: ((T) -> Long?)? = null, + itemQwinsta: ((T) -> Boolean)? = null, + onQwinsta: ((T) -> Unit)? = null, + qwinstaLoading: Boolean = false, filterLabel: String = "Severity", hostnameFilter: String = "", headerContent: @Composable () -> Unit = {}, @@ -401,6 +423,9 @@ private fun PagedListScreen( onClick = { onOpen(itemKey(item)) }, rdgFlap = itemRdgFlap?.invoke(item) == true, 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, rdgFlap: Boolean = false, rdgFlapPairId: Long? = null, + showQwinsta: Boolean = false, + onQwinstaClick: (() -> Unit)? = null, + qwinstaLoading: Boolean = false, ) { Card( Modifier .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable(onClick = onClick), + .padding(vertical = 4.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (rdgFlap) { - RdgFlapBadge(pairEventId = rdgFlapPairId) + Column( + 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) } } } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt index 854dcc2..a85d1d3 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt @@ -37,7 +37,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List").trim(), userName = userName, id = id, state = parts.drop(3).joinToString(" "), @@ -52,7 +52,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List").trim(), userName = parts[1], id = id, state = parts.drop(3).joinToString(" "),