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:
@@ -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() }
|
||||
?: "Команда завершилась с ошибкой"
|
||||
}
|
||||
Reference in New Issue
Block a user