feat: RDG flap qwinsta/logoff via SAC API (0.5.8)

Mobile triggers POST events/.../actions/qwinsta and logoff; SAC runs WinRM
on the client PC. List and event detail show RDG flap marker.
This commit is contained in:
PTah
2026-06-20 19:02:59 +10:00
parent 823ac4df57
commit 4f9a241cea
10 changed files with 284 additions and 98 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca"
minSdk = 26
targetSdk = 35
versionCode = 21
versionName = "0.5.7"
versionCode = 22
versionName = "0.5.8"
}
buildTypes {
@@ -36,6 +36,15 @@ interface SacApi {
@GET("api/v1/events/{id}")
suspend fun getEvent(@Path("id") id: Long): EventDetail
@POST("api/v1/events/{id}/actions/qwinsta")
suspend fun postEventQwinsta(@Path("id") id: Long): AgentCommandResponse
@POST("api/v1/events/{id}/actions/logoff")
suspend fun postEventLogoff(
@Path("id") id: Long,
@Body body: LogoffActionRequest,
): AgentCommandResponse
@GET("api/v1/problems")
suspend fun listProblems(
@Query("page") page: Int = 1,
@@ -58,6 +58,9 @@ data class EventSummary(
val title: String,
val summary: String,
@SerialName("actor_user") val actorUser: String? = null,
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
)
@Serializable
@@ -80,6 +83,10 @@ data class EventDetail(
val severity: String,
val title: String,
val summary: String,
@SerialName("actor_user") val actorUser: String? = null,
@SerialName("rdg_flap") val rdgFlap: Boolean = false,
@SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null,
@SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null,
val details: JsonElement? = null,
val raw: JsonElement? = null,
val payload: JsonElement? = null,
@@ -192,6 +199,23 @@ data class ProblemActionResponse(
val status: String,
)
@Serializable
data class LogoffActionRequest(
@SerialName("session_id") val sessionId: Int,
)
@Serializable
data class AgentCommandResponse(
@SerialName("command_uuid") val commandUuid: String,
@SerialName("command_type") val commandType: String,
val status: String,
@SerialName("result_stdout") val resultStdout: String? = null,
@SerialName("result_stderr") val resultStderr: String? = null,
val target: String? = null,
@SerialName("client_hostname") val clientHostname: String? = null,
@SerialName("internal_ip") val internalIp: String? = null,
)
@Serializable
data class ApiErrorBody(
val detail: String? = null,
@@ -117,7 +117,7 @@ class SacRepository(context: Context) {
}
val okhttp = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
.addInterceptor(authInterceptor)
@@ -17,7 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -55,7 +58,13 @@ import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.NotificationSoundSettings
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.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
import ru.kalinamall.seaca.ui.RingtonePicker
@@ -78,6 +87,14 @@ 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 dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
@@ -93,8 +110,49 @@ 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)
},
modifier = Modifier.padding(vertical = 4.dp),
) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") }
}
}
Field("Severity", d.severity)
Field(
"Тип",
@@ -110,6 +168,75 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
EventDetailsSection(d.type, d.details, d.payload)
}
}
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("Закрыть") }
},
)
}
}
@Composable
@@ -47,7 +47,7 @@ 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.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов",
@@ -189,7 +189,10 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
res.items to res.total
},
itemKey = EventSummary::id,
itemLabel = { "${it.severity} · ${it.title}" },
itemLabel = {
val prefix = if (hasRdgFlapUi(it)) "RDG flap · " else ""
"$prefix${it.severity} · ${it.title}"
},
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"),
@@ -0,0 +1,71 @@
package ru.kalinamall.seaca.ui.util
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.EventSummary
data class QwinstaSessionRow(
val sessionName: String,
val userName: String,
val id: Int,
val state: String,
)
fun hasRdgFlapUi(event: EventSummary): Boolean =
event.rdgFlap || event.rdgFlapQwinstaEventId != null
fun rdgFlapQwinstaEventId(event: EventSummary): Long? =
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
fun hasRdgFlapUi(event: EventDetail): Boolean =
event.rdgFlap || event.rdgFlapQwinstaEventId != null
fun rdgFlapQwinstaEventId(event: EventDetail): Long? =
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessionRow> {
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
val rows = mutableListOf<QwinstaSessionRow>()
for (line in lines) {
if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue
val parts = line.split(Regex("\\s+"))
if (parts.size < 4) continue
val id = parts[2].toIntOrNull() ?: continue
val userName = parts[1]
if (!actorUser.isNullOrBlank()) {
val norm: (String) -> String = { it.replace(Regex("^B26\\\\", RegexOption.IGNORE_CASE), "").lowercase() }
if (!norm(userName).contains(norm(actorUser))) continue
}
rows.add(
QwinstaSessionRow(
sessionName = parts[0],
userName = userName,
id = id,
state = parts.drop(3).joinToString(" "),
),
)
}
if (rows.isEmpty() && stdout.isNotBlank()) {
for (line in lines) {
if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue
val parts = line.split(Regex("\\s+"))
if (parts.size < 4) continue
val id = parts[2].toIntOrNull() ?: continue
rows.add(
QwinstaSessionRow(
sessionName = parts[0],
userName = parts[1],
id = id,
state = parts.drop(3).joinToString(" "),
),
)
}
}
return rows
}
fun agentCommandError(cmd: ru.kalinamall.seaca.data.AgentCommandResponse): String? {
if (cmd.status != "failed") return null
return cmd.resultStderr?.takeIf { it.isNotBlank() }
?: cmd.resultStdout?.takeIf { it.isNotBlank() }
?: "Команда завершилась с ошибкой"
}