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"
|
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("&", "&")
|
.replace("&", "&")
|
||||||
.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>>()
|
||||||
|
|||||||
Reference in New Issue
Block a user