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:
2026-06-20 19:19:09 +10:00
parent c040b7a6c6
commit 363471c563
6 changed files with 395 additions and 124 deletions
+2 -2
View File
@@ -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 {
@@ -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.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<EventDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
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 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
@@ -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 <T> PagedListScreen(
severities: List<String?>,
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 <T> 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)
}
}
}
@@ -37,7 +37,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessio
}
rows.add(
QwinstaSessionRow(
sessionName = parts[0],
sessionName = parts[0].removePrefix(">").trim(),
userName = userName,
id = id,
state = parts.drop(3).joinToString(" "),
@@ -52,7 +52,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessio
val id = parts[2].toIntOrNull() ?: continue
rows.add(
QwinstaSessionRow(
sessionName = parts[0],
sessionName = parts[0].removePrefix(">").trim(),
userName = parts[1],
id = id,
state = parts.drop(3).joinToString(" "),