feat: readable event details for inventory, lifecycle, login (0.4.9)

Format agent.inventory, lifecycle, login and bruteforce events as labeled
fields instead of raw JSON on the event detail screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-11 15:45:33 +10:00
parent 8ba466c10e
commit 47c51aecd3
3 changed files with 275 additions and 6 deletions
@@ -36,6 +36,7 @@ import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.R
import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel
import ru.kalinamall.seaca.ui.util.formatEventDetails
import ru.kalinamall.seaca.ui.util.inventoryFieldLines
import ru.kalinamall.seaca.ui.util.isDailyReportType
import ru.kalinamall.seaca.ui.util.reportTextFromDetails
@@ -71,11 +72,30 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) }
?: Field("Отчёт", "Полный текст отчёта недоступен")
} else {
d.details?.let { Field("Детали", it.toPrettyString()) }
EventDetailsSection(d.type, d.details, d.payload)
}
}
}
@Composable
private fun EventDetailsSection(type: String, details: JsonElement?, payload: JsonElement?) {
val formatted = formatEventDetails(type, details, payload)
if (formatted?.hasContent == true) {
Text(
"Детали",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp),
)
formatted.multilineText?.let { text ->
MultilineField(formatted.multilineLabel ?: "Сообщение", text)
}
formatted.fields.forEach { (label, value) -> Field(label, value) }
} else if (details != null) {
Field("Детали", details.toPrettyString())
}
}
@Composable
fun ProblemDetailScreen(
repo: SacRepository,
@@ -2,11 +2,21 @@ package ru.kalinamall.seaca.ui.util
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class FormattedEventDetails(
val fields: List<Pair<String, String>> = emptyList(),
val multilineLabel: String? = null,
val multilineText: String? = null,
) {
val hasContent: Boolean
get() = fields.isNotEmpty() || !multilineText.isNullOrBlank()
}
fun isDailyReportType(type: String): Boolean =
type == "report.daily.ssh" || type == "report.daily.rdp"
@@ -16,6 +26,11 @@ fun dailyReportTypeLabel(type: String): String = when (type) {
else -> type
}
private fun isLoginEventType(type: String): Boolean =
type.startsWith("rdp.login.") ||
type.startsWith("ssh.login.") ||
type.startsWith("agent.login.")
/** Текст ежедневного отчёта из details (не сырой JSON). */
fun reportTextFromDetails(details: JsonElement?): String? {
if (details == null) return null
@@ -48,13 +63,247 @@ private fun stripHtmlTags(html: String): String =
.replace("&amp;", "&")
.trim()
private fun jsonString(obj: JsonObject, key: String): String? =
obj[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
fun formatEventDetails(
type: String,
details: JsonElement?,
payload: JsonElement? = null,
): FormattedEventDetails? {
val obj = details as? JsonObject ?: return null
return when (type) {
"agent.lifecycle" -> formatLifecycleDetails(obj, payload)
"agent.inventory" -> formatInventoryEventDetails(obj)
"rdp.bruteforce.burst" -> formatBruteforceBurstDetails(obj)
else -> when {
isLoginEventType(type) -> formatLoginDetails(obj)
else -> {
val fields = extractStructuredDetailFields(obj)
if (fields.isEmpty()) null else FormattedEventDetails(fields = fields)
}
}
}
}
private fun formatLifecycleDetails(obj: JsonObject, payload: JsonElement?): FormattedEventDetails {
jsonString(obj, "notification_body")?.let { body ->
return FormattedEventDetails(
multilineLabel = "Сообщение",
multilineText = normalizeReportPlainText(body),
)
}
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "lifecycle")?.let { fields += "Событие" to lifecycleLabel(it) }
jsonString(obj, "trigger")?.let { fields += "Причина" to lifecycleTriggerLabel(it) }
sourceProductLine(payload)?.let { fields += it }
return FormattedEventDetails(fields = fields)
}
private fun formatInventoryEventDetails(obj: JsonObject): FormattedEventDetails {
val changes = obj["hardware_changes"]?.jsonArray
if (changes != null && changes.isNotEmpty()) {
val fields = changes.mapIndexedNotNull { index, element ->
val change = element.jsonObject
val field = jsonString(change, "field") ?: return@mapIndexedNotNull null
val old = primitiveDisplay(change["old"]) ?: ""
val new = primitiveDisplay(change["new"]) ?: ""
"Изменение ${index + 1}" to "${hardwareFieldLabel(field)}: $old$new"
}
return FormattedEventDetails(fields = fields)
}
val inventory = obj["inventory"]
if (inventory != null) {
val lines = inventoryFieldLines(inventory)
if (lines.isNotEmpty()) return FormattedEventDetails(fields = lines)
}
val fallback = extractStructuredDetailFields(obj)
return if (fallback.isEmpty()) FormattedEventDetails() else FormattedEventDetails(fields = fallback)
}
private fun formatBruteforceBurstDetails(obj: JsonObject): FormattedEventDetails {
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "tier")?.let { fields += "Уровень" to it }
primitiveDisplay(obj["count"])?.let { fields += "Количество" to it }
jsonString(obj, "bucket_key")?.let { fields += "Ключ" to it }
jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it }
jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it }
if (fields.isEmpty()) {
return FormattedEventDetails(fields = extractStructuredDetailFields(obj))
}
return FormattedEventDetails(fields = fields)
}
private fun formatLoginDetails(obj: JsonObject): FormattedEventDetails {
jsonString(obj, "notification_body")?.let { body ->
return FormattedEventDetails(
multilineLabel = "Сообщение",
multilineText = normalizeReportPlainText(body),
)
}
val fields = mutableListOf<Pair<String, String>>()
jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it }
jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it }
jsonString(obj, "workstation_name", "computer_name")?.let { fields += "Рабочая станция" to it }
jsonString(obj, "process_name", "process")?.let { fields += "Процесс" to it }
jsonPrimitiveString(obj, "logon_type")?.let { fields += "Тип входа" to logonTypeLabel(it) }
jsonPrimitiveString(obj, "port")?.let { fields += "Порт" to it }
val attempt = jsonPrimitiveString(obj, "attempt_number")
val maxAttempts = jsonPrimitiveString(obj, "max_attempts")
if (attempt != null && maxAttempts != null) {
fields += "Попытка" to "$attempt / $maxAttempts"
}
jsonPrimitiveString(obj, "event_id_windows")?.let { fields += "Event ID Windows" to it }
if (fields.isEmpty()) {
return FormattedEventDetails(fields = extractStructuredDetailFields(obj))
}
return FormattedEventDetails(fields = fields)
}
private fun extractStructuredDetailFields(obj: JsonObject): List<Pair<String, String>> {
val fields = mutableListOf<Pair<String, String>>()
val consumed = mutableSetOf<String>()
fun add(label: String, vararg keys: String) {
jsonString(obj, *keys)?.let {
fields += label to it
keys.forEach { key -> consumed += key }
}
jsonPrimitiveString(obj, *keys)?.let {
fields += label to it
keys.forEach { key -> consumed += key }
}
}
add("Пользователь", "user", "username", "shadower_user", "target_user")
add("IP", "ip_address", "source_ip", "ip", "external_ip", "internal_ip")
add("Рабочая станция", "workstation_name", "computer_name")
add("Процесс", "process_name", "process")
add("Порт", "port")
add("Команда", "command")
add("Share", "share_name")
add("Путь", "share_path")
add("ResourceUri", "resource_uri")
add("Session ID", "session_id", "target_session_id")
add("Event ID Windows", "event_id_windows")
add("Уровень", "tier", "risk_level")
add("Количество", "count")
add("Ключ", "bucket_key")
jsonPrimitiveString(obj, "logon_type")?.let {
fields += "Тип входа" to logonTypeLabel(it)
consumed += "logon_type"
}
obj.forEach { (key, value) ->
if (key in consumed || key in SKIP_DETAIL_KEYS) return@forEach
if (value is JsonPrimitive) {
val text = primitiveDisplay(value) ?: return@forEach
fields += detailKeyLabel(key) to text
}
}
return fields
}
private val SKIP_DETAIL_KEYS = setOf(
"telegram_via",
"generated_by",
"report_body",
"report_html",
"inventory",
"hardware_changes",
"notification_body",
"lifecycle",
"trigger",
)
private fun detailKeyLabel(key: String): String = when (key) {
"user" -> "Пользователь"
"username" -> "Пользователь"
"source_ip" -> "IP"
"ip_address" -> "IP"
"workstation_name" -> "Рабочая станция"
"event_id_windows" -> "Event ID Windows"
"logon_type" -> "Тип входа"
"attempt_number" -> "Попытка"
"max_attempts" -> "Макс. попыток"
else -> key.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
private fun lifecycleLabel(value: String): String = when (value.lowercase()) {
"started" -> "Запущен"
"stopped" -> "Остановлен"
"settings_reloaded" -> "Настройки перечитаны"
else -> value
}
private fun lifecycleTriggerLabel(value: String): String = when (value.lowercase()) {
"boot" -> "Загрузка ОС / планировщик"
"deploy_recycle" -> "Обновление скрипта (recycle)"
"settings_reload" -> "Graceful restart (settings)"
"manual_recycle" -> "Ручной recycle"
"shutdown" -> "Остановка процесса"
else -> value
}
private fun logonTypeLabel(raw: String): String {
val code = raw.trim().toIntOrNull()
val name = when (code) {
2 -> "Interactive"
3 -> "Network"
4 -> "Batch"
5 -> "Service"
7 -> "Unlock"
8 -> "NetworkCleartext"
9 -> "NewCredentials"
10 -> "RemoteInteractive"
11 -> "CachedInteractive"
else -> null
}
return when {
name != null && code != null -> "$name ($code)"
else -> raw
}
}
private fun hardwareFieldLabel(field: String): String = when (field) {
"memory_gb" -> "Память (GB)"
"processor" -> "Процессор"
else -> field
}
private fun sourceProductLine(payload: JsonElement?): Pair<String, String>? {
val source = payload?.jsonObject?.get("source")?.jsonObject ?: return null
val product = jsonString(source, "product")
val version = jsonString(source, "product_version")
val line = listOfNotNull(product, version).joinToString(" ").trim()
return if (line.isEmpty()) null else "Агент" to line
}
private fun jsonString(obj: JsonObject, vararg keys: String): String? {
for (key in keys) {
val value = obj[key]?.jsonPrimitive?.contentOrNull?.trim()
if (!value.isNullOrEmpty()) return value
}
return null
}
private fun jsonPrimitiveString(obj: JsonObject, vararg keys: String): String? {
for (key in keys) {
val el = obj[key] ?: continue
primitiveDisplay(el)?.let { return it }
}
return null
}
private fun primitiveDisplay(el: JsonElement?): String? =
when (el) {
is JsonPrimitive -> el.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
else -> null
}
private fun jsonInt(obj: JsonObject, key: String): Int? =
obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
/** Плоский список полей инвентаризации для карточки хоста. */
/** Плоский список полей инвентаризации для карточки хоста / agent.inventory. */
fun inventoryFieldLines(inventory: JsonElement): List<Pair<String, String>> {
val obj = inventory as? JsonObject ?: return emptyList()
val out = mutableListOf<Pair<String, String>>()