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
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca" applicationId = "ru.kalinamall.seaca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 12 versionCode = 13
versionName = "0.4.8" versionName = "0.4.9"
} }
buildTypes { buildTypes {
@@ -36,6 +36,7 @@ import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.R import ru.kalinamall.seaca.R
import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel 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.inventoryFieldLines
import ru.kalinamall.seaca.ui.util.isDailyReportType import ru.kalinamall.seaca.ui.util.isDailyReportType
import ru.kalinamall.seaca.ui.util.reportTextFromDetails 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) } reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) }
?: Field("Отчёт", "Полный текст отчёта недоступен") ?: Field("Отчёт", "Полный текст отчёта недоступен")
} else { } 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 @Composable
fun ProblemDetailScreen( fun ProblemDetailScreen(
repo: SacRepository, repo: SacRepository,
@@ -2,11 +2,21 @@ package ru.kalinamall.seaca.ui.util
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive 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 = fun isDailyReportType(type: String): Boolean =
type == "report.daily.ssh" || type == "report.daily.rdp" type == "report.daily.ssh" || type == "report.daily.rdp"
@@ -16,6 +26,11 @@ fun dailyReportTypeLabel(type: String): String = when (type) {
else -> type else -> type
} }
private fun isLoginEventType(type: String): Boolean =
type.startsWith("rdp.login.") ||
type.startsWith("ssh.login.") ||
type.startsWith("agent.login.")
/** Текст ежедневного отчёта из details (не сырой JSON). */ /** Текст ежедневного отчёта из details (не сырой JSON). */
fun reportTextFromDetails(details: JsonElement?): String? { fun reportTextFromDetails(details: JsonElement?): String? {
if (details == null) return null if (details == null) return null
@@ -48,13 +63,247 @@ private fun stripHtmlTags(html: String): String =
.replace("&amp;", "&") .replace("&amp;", "&")
.trim() .trim()
private fun jsonString(obj: JsonObject, key: String): String? = fun formatEventDetails(
obj[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() } 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? = private fun jsonInt(obj: JsonObject, key: String): Int? =
obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull() obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
/** Плоский список полей инвентаризации для карточки хоста. */ /** Плоский список полей инвентаризации для карточки хоста / agent.inventory. */
fun inventoryFieldLines(inventory: JsonElement): List<Pair<String, String>> { fun inventoryFieldLines(inventory: JsonElement): List<Pair<String, String>> {
val obj = inventory as? JsonObject ?: return emptyList() val obj = inventory as? JsonObject ?: return emptyList()
val out = mutableListOf<Pair<String, String>>() val out = mutableListOf<Pair<String, String>>()