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:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "ru.kalinamall.seaca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 12
|
||||
versionName = "0.4.8"
|
||||
versionCode = 13
|
||||
versionName = "0.4.9"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -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("&", "&")
|
||||
.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>>()
|
||||
|
||||
Reference in New Issue
Block a user