feat: push notification modes and readable detail views (0.4.3)
Add three notification modes in device settings: push with sound, push silent, and off. Improve event/host detail formatting for reports and inventory. 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 = 5
|
versionCode = 7
|
||||||
versionName = "0.4.1"
|
versionName = "0.4.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.kalinamall.seaca.data
|
||||||
|
|
||||||
|
enum class NotificationMode(
|
||||||
|
val storageKey: String,
|
||||||
|
val label: String,
|
||||||
|
) {
|
||||||
|
PUSH_SOUND("push_sound", "Push и звук"),
|
||||||
|
PUSH_SILENT("push_silent", "Push без звука"),
|
||||||
|
OFF("off", "Без push и звука"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromStorage(value: String?): NotificationMode =
|
||||||
|
entries.firstOrNull { it.storageKey == value } ?: PUSH_SOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,17 @@ class SessionStore(private val context: Context) {
|
|||||||
!prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS)
|
!prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val notificationMode: Flow<NotificationMode> = context.dataStore.data.map { prefs ->
|
||||||
|
NotificationMode.fromStorage(prefs[KEY_NOTIFICATION_MODE])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNotificationMode(): NotificationMode =
|
||||||
|
NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE])
|
||||||
|
|
||||||
|
suspend fun setNotificationMode(mode: NotificationMode) {
|
||||||
|
context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL]
|
suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL]
|
||||||
|
|
||||||
suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false
|
suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false
|
||||||
@@ -90,6 +101,7 @@ class SessionStore(private val context: Context) {
|
|||||||
private val KEY_BASE_URL = stringPreferencesKey("base_url")
|
private val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||||
private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid")
|
private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid")
|
||||||
private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs")
|
private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs")
|
||||||
|
private val KEY_NOTIFICATION_MODE = stringPreferencesKey("notification_mode")
|
||||||
private const val KEY_ACCESS = "access_token"
|
private const val KEY_ACCESS = "access_token"
|
||||||
private const val KEY_REFRESH = "refresh_token"
|
private const val KEY_REFRESH = "refresh_token"
|
||||||
private const val KEY_DEVICE_ID = "device_id"
|
private const val KEY_DEVICE_ID = "device_id"
|
||||||
|
|||||||
@@ -14,21 +14,50 @@ object NotificationHelper {
|
|||||||
const val CHANNEL_DEFAULT = "sac_default"
|
const val CHANNEL_DEFAULT = "sac_default"
|
||||||
const val CHANNEL_HIGH = "sac_high"
|
const val CHANNEL_HIGH = "sac_high"
|
||||||
const val CHANNEL_CRITICAL = "sac_critical"
|
const val CHANNEL_CRITICAL = "sac_critical"
|
||||||
|
const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
|
||||||
|
const val CHANNEL_HIGH_SILENT = "sac_high_silent"
|
||||||
|
const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
|
||||||
|
|
||||||
fun ensureChannels(context: Context) {
|
fun ensureChannels(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val mgr = context.getSystemService(NotificationManager::class.java)
|
val mgr = context.getSystemService(NotificationManager::class.java)
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_DEFAULT, context.getString(R.string.channel_default), NotificationManager.IMPORTANCE_DEFAULT),
|
NotificationChannel(
|
||||||
|
CHANNEL_DEFAULT,
|
||||||
|
context.getString(R.string.channel_default),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_HIGH, context.getString(R.string.channel_high), NotificationManager.IMPORTANCE_HIGH),
|
NotificationChannel(
|
||||||
|
CHANNEL_HIGH,
|
||||||
|
context.getString(R.string.channel_high),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_CRITICAL, context.getString(R.string.channel_critical), NotificationManager.IMPORTANCE_HIGH),
|
NotificationChannel(
|
||||||
|
CHANNEL_CRITICAL,
|
||||||
|
context.getString(R.string.channel_critical),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent))
|
||||||
|
mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent))
|
||||||
|
mgr.createNotificationChannel(silentChannel(context, CHANNEL_CRITICAL_SILENT, R.string.channel_critical_silent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun silentChannel(context: Context, id: String, nameRes: Int): NotificationChannel =
|
||||||
|
NotificationChannel(id, context.getString(nameRes), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
setSound(null, null)
|
||||||
|
enableVibration(false)
|
||||||
|
setShowBadge(true)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
setAllowBubbles(false)
|
||||||
|
}
|
||||||
|
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
|
||||||
|
}
|
||||||
|
|
||||||
fun show(
|
fun show(
|
||||||
context: Context,
|
context: Context,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -36,13 +65,10 @@ object NotificationHelper {
|
|||||||
severity: String?,
|
severity: String?,
|
||||||
kind: String?,
|
kind: String?,
|
||||||
id: String?,
|
id: String?,
|
||||||
|
silent: Boolean = false,
|
||||||
) {
|
) {
|
||||||
ensureChannels(context)
|
ensureChannels(context)
|
||||||
val channel = when (severity?.lowercase()) {
|
val channel = resolveChannel(severity, silent)
|
||||||
"critical" -> CHANNEL_CRITICAL
|
|
||||||
"high" -> CHANNEL_HIGH
|
|
||||||
else -> CHANNEL_DEFAULT
|
|
||||||
}
|
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
when (kind) {
|
when (kind) {
|
||||||
@@ -56,21 +82,44 @@ object NotificationHelper {
|
|||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
val notification = NotificationCompat.Builder(context, channel)
|
val builder = NotificationCompat.Builder(context, channel)
|
||||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
.setPriority(
|
.setPriority(
|
||||||
when (channel) {
|
when {
|
||||||
CHANNEL_CRITICAL -> NotificationCompat.PRIORITY_MAX
|
silent -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
CHANNEL_HIGH -> NotificationCompat.PRIORITY_HIGH
|
channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT ->
|
||||||
|
NotificationCompat.PRIORITY_MAX
|
||||||
|
channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT ->
|
||||||
|
NotificationCompat.PRIORITY_HIGH
|
||||||
else -> NotificationCompat.PRIORITY_DEFAULT
|
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.build()
|
if (silent) {
|
||||||
|
builder.setSilent(true)
|
||||||
|
builder.setSound(null)
|
||||||
|
builder.setVibrate(null)
|
||||||
|
builder.setDefaults(0)
|
||||||
|
}
|
||||||
context.getSystemService(NotificationManager::class.java)
|
context.getSystemService(NotificationManager::class.java)
|
||||||
.notify((id ?: title).hashCode(), notification)
|
.notify((id ?: title).hashCode(), builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveChannel(severity: String?, silent: Boolean): String {
|
||||||
|
if (silent) {
|
||||||
|
return when (severity?.lowercase()) {
|
||||||
|
"critical" -> CHANNEL_CRITICAL_SILENT
|
||||||
|
"high" -> CHANNEL_HIGH_SILENT
|
||||||
|
else -> CHANNEL_DEFAULT_SILENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return when (severity?.lowercase()) {
|
||||||
|
"critical" -> CHANNEL_CRITICAL
|
||||||
|
"high" -> CHANNEL_HIGH
|
||||||
|
else -> CHANNEL_DEFAULT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.kalinamall.seaca.data.NotificationMode
|
||||||
import ru.kalinamall.seaca.data.SacRepository
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
|
|
||||||
class SeacaMessagingService : FirebaseMessagingService() {
|
class SeacaMessagingService : FirebaseMessagingService() {
|
||||||
@@ -13,6 +14,16 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
|||||||
private val repo by lazy { SacRepository(applicationContext) }
|
private val repo by lazy { SacRepository(applicationContext) }
|
||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
scope.launch {
|
||||||
|
when (repo.session.getNotificationMode()) {
|
||||||
|
NotificationMode.OFF -> return@launch
|
||||||
|
NotificationMode.PUSH_SILENT -> show(message, silent = true)
|
||||||
|
NotificationMode.PUSH_SOUND -> show(message, silent = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun show(message: RemoteMessage, silent: Boolean) {
|
||||||
val data = message.data
|
val data = message.data
|
||||||
val title = message.notification?.title ?: data["title"] ?: "SAC"
|
val title = message.notification?.title ?: data["title"] ?: "SAC"
|
||||||
val body = message.notification?.body ?: data["body"] ?: ""
|
val body = message.notification?.body ?: data["body"] ?: ""
|
||||||
@@ -23,6 +34,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
|||||||
severity = data["severity"],
|
severity = data["severity"],
|
||||||
kind = data["kind"],
|
kind = data["kind"],
|
||||||
id = data["id"],
|
id = data["id"],
|
||||||
|
silent = silent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,32 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import ru.kalinamall.seaca.data.EventDetail
|
import ru.kalinamall.seaca.data.EventDetail
|
||||||
import ru.kalinamall.seaca.data.HostDetail
|
import ru.kalinamall.seaca.data.HostDetail
|
||||||
|
import ru.kalinamall.seaca.data.NotificationMode
|
||||||
import ru.kalinamall.seaca.data.ProblemDetail
|
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.ui.util.dailyReportTypeLabel
|
||||||
|
import ru.kalinamall.seaca.ui.util.inventoryFieldLines
|
||||||
|
import ru.kalinamall.seaca.ui.util.isDailyReportType
|
||||||
|
import ru.kalinamall.seaca.ui.util.reportTextFromDetails
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
||||||
@@ -48,11 +58,19 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
||||||
val d = data ?: return@DetailFrame
|
val d = data ?: return@DetailFrame
|
||||||
Field("Severity", d.severity)
|
Field("Severity", d.severity)
|
||||||
Field("Тип", d.type)
|
Field(
|
||||||
|
"Тип",
|
||||||
|
if (isDailyReportType(d.type)) dailyReportTypeLabel(d.type) else d.type,
|
||||||
|
)
|
||||||
Field("Хост", d.hostname)
|
Field("Хост", d.hostname)
|
||||||
Field("Время", d.occurredAt)
|
Field("Время", d.occurredAt)
|
||||||
Field("Кратко", d.summary)
|
Field("Кратко", d.summary)
|
||||||
d.details?.let { Field("Детали", it.toPrettyString()) }
|
if (isDailyReportType(d.type)) {
|
||||||
|
reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) }
|
||||||
|
?: Field("Отчёт", "Полный текст отчёта недоступен")
|
||||||
|
} else {
|
||||||
|
d.details?.let { Field("Детали", it.toPrettyString()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +183,15 @@ fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
|||||||
Field("ОС", "${d.osFamily} ${d.osVersion.orEmpty()}".trim())
|
Field("ОС", "${d.osFamily} ${d.osVersion.orEmpty()}".trim())
|
||||||
Field("Агент", d.agentStatus)
|
Field("Агент", d.agentStatus)
|
||||||
Field("Последний контакт", d.lastSeenAt)
|
Field("Последний контакт", d.lastSeenAt)
|
||||||
d.inventory?.let { Field("Inventory", it.toPrettyString()) }
|
d.inventory?.let { inv ->
|
||||||
|
val lines = inventoryFieldLines(inv)
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
Field("Inventory", "—")
|
||||||
|
} else {
|
||||||
|
Text("Железо и ПО", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
lines.forEach { (label, value) -> Field(label, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +201,7 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
|||||||
var username by remember { mutableStateOf<String?>(null) }
|
var username by remember { mutableStateOf<String?>(null) }
|
||||||
var role by remember { mutableStateOf<String?>(null) }
|
var role by remember { mutableStateOf<String?>(null) }
|
||||||
var deviceId by remember { mutableStateOf(0) }
|
var deviceId by remember { mutableStateOf(0) }
|
||||||
|
val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -187,6 +214,7 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -196,6 +224,33 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
|||||||
Field("Роль", role ?: "—")
|
Field("Роль", role ?: "—")
|
||||||
Field("Device ID", deviceId.toString())
|
Field("Device ID", deviceId.toString())
|
||||||
Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME)
|
Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.notification_mode_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
NotificationMode.entries.forEach { mode ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = notificationMode == mode,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { repo.session.setNotificationMode(mode) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
mode.label,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -235,13 +290,35 @@ private fun DetailFrame(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Field(label: String, value: String) {
|
private fun MultilineField(label: String, value: String) {
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Field(label: String, value: String) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import ru.kalinamall.seaca.data.ProblemListResponse
|
|||||||
import ru.kalinamall.seaca.data.ProblemSummary
|
import ru.kalinamall.seaca.data.ProblemSummary
|
||||||
import ru.kalinamall.seaca.data.SacRepository
|
import ru.kalinamall.seaca.data.SacRepository
|
||||||
|
|
||||||
|
private val REPORT_TYPE_OPTIONS = listOf(
|
||||||
|
"report.daily.ssh" to "Отчёты ssh клиентов",
|
||||||
|
"report.daily.rdp" to "Отчёты RDP клиентов",
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(repo: SacRepository) {
|
fun DashboardScreen(repo: SacRepository) {
|
||||||
@@ -173,25 +178,25 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
|||||||
|
|
||||||
LaunchedEffect(page, reportType) { load() }
|
LaunchedEffect(page, reportType) { load() }
|
||||||
|
|
||||||
|
val reportTypeLabel = REPORT_TYPE_OPTIONS.firstOrNull { it.first == reportType }?.second ?: reportType
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
value = reportType,
|
value = reportTypeLabel,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
label = { Text("Тип отчёта") },
|
label = { Text("Тип отчёта") },
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
DropdownMenuItem(
|
REPORT_TYPE_OPTIONS.forEach { (value, label) ->
|
||||||
text = { Text("report.daily.ssh") },
|
DropdownMenuItem(
|
||||||
onClick = { reportType = "report.daily.ssh"; expanded = false; page = 1 },
|
text = { Text(label) },
|
||||||
)
|
onClick = { reportType = value; expanded = false; page = 1 },
|
||||||
DropdownMenuItem(
|
)
|
||||||
text = { Text("report.daily.rdp") },
|
}
|
||||||
onClick = { reportType = "report.daily.rdp"; expanded = false; page = 1 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package ru.kalinamall.seaca.ui.util
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
fun isDailyReportType(type: String): Boolean =
|
||||||
|
type == "report.daily.ssh" || type == "report.daily.rdp"
|
||||||
|
|
||||||
|
fun dailyReportTypeLabel(type: String): String = when (type) {
|
||||||
|
"report.daily.ssh" -> "Отчёты ssh клиентов"
|
||||||
|
"report.daily.rdp" -> "Отчёты RDP клиентов"
|
||||||
|
else -> type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст ежедневного отчёта из details (не сырой JSON). */
|
||||||
|
fun reportTextFromDetails(details: JsonElement?): String? {
|
||||||
|
if (details == null) return null
|
||||||
|
val obj = details as? JsonObject ?: return null
|
||||||
|
val body = obj["report_body"]?.jsonPrimitive?.contentOrNull?.trim()
|
||||||
|
if (!body.isNullOrEmpty()) {
|
||||||
|
return normalizeReportPlainText(body)
|
||||||
|
}
|
||||||
|
val html = obj["report_html"]?.jsonPrimitive?.contentOrNull?.trim()
|
||||||
|
if (!html.isNullOrEmpty()) {
|
||||||
|
return normalizeReportPlainText(stripHtmlTags(html))
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeReportPlainText(body: String): String =
|
||||||
|
body
|
||||||
|
.replace("\r\n", "\n")
|
||||||
|
.replace("\\n", "\n")
|
||||||
|
.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
private fun stripHtmlTags(html: String): String =
|
||||||
|
html
|
||||||
|
.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
|
||||||
|
.replace(Regex("<[^>]+>"), "")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
private fun jsonString(obj: JsonObject, key: String): String? =
|
||||||
|
obj[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private fun jsonInt(obj: JsonObject, key: String): Int? =
|
||||||
|
obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
|
||||||
|
|
||||||
|
/** Плоский список полей инвентаризации для карточки хоста. */
|
||||||
|
fun inventoryFieldLines(inventory: JsonElement): List<Pair<String, String>> {
|
||||||
|
val obj = inventory as? JsonObject ?: return emptyList()
|
||||||
|
val out = mutableListOf<Pair<String, String>>()
|
||||||
|
|
||||||
|
jsonString(obj, "computer_name")?.let { out += "Компьютер" to it }
|
||||||
|
jsonInt(obj, "memory_gb")?.let { out += "Память" to "$it GB" }
|
||||||
|
|
||||||
|
obj["windows"]?.jsonObject?.let { w ->
|
||||||
|
listOf("product_name" to "Продукт", "version" to "Версия", "os_hardware_abstraction_layer" to "HAL")
|
||||||
|
.forEach { (key, label) ->
|
||||||
|
jsonString(w, key)?.let { out += label to it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
obj["motherboard"]?.jsonObject?.let { m ->
|
||||||
|
val line = listOfNotNull(jsonString(m, "manufacturer"), jsonString(m, "product"))
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
if (line.isNotEmpty()) out += "Материнская плата" to line
|
||||||
|
}
|
||||||
|
|
||||||
|
obj["processor"]?.jsonArray?.forEachIndexed { i, el ->
|
||||||
|
val p = el.jsonObject
|
||||||
|
val name = jsonString(p, "name") ?: return@forEachIndexed
|
||||||
|
val cores = jsonInt(p, "cores")
|
||||||
|
val logical = jsonInt(p, "logical_processors")
|
||||||
|
val suffix = when {
|
||||||
|
cores != null && logical != null -> " — $cores ядер / $logical потоков"
|
||||||
|
cores != null -> " — $cores ядер"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
out += "Процессор ${i + 1}" to "$name$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
obj["disks"]?.jsonArray?.forEachIndexed { i, el ->
|
||||||
|
val d = el.jsonObject
|
||||||
|
val name = jsonString(d, "friendly_name") ?: "—"
|
||||||
|
val media = jsonString(d, "media_type") ?: "—"
|
||||||
|
val size = jsonInt(d, "size_gb")?.toString() ?: "—"
|
||||||
|
out += "Диск ${i + 1}" to "$name · $media · ${size} GB"
|
||||||
|
}
|
||||||
|
|
||||||
|
obj["video"]?.jsonArray?.forEachIndexed { i, el ->
|
||||||
|
jsonString(el.jsonObject, "name")?.let { out += "Видео ${i + 1}" to it }
|
||||||
|
}
|
||||||
|
|
||||||
|
obj["ipv4"]?.jsonArray?.let { arr ->
|
||||||
|
val ips = arr.mapNotNull { it.jsonPrimitive.contentOrNull?.trim() }.filter { it.isNotEmpty() }
|
||||||
|
if (ips.isNotEmpty()) out += "IPv4" to ips.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -4,4 +4,8 @@
|
|||||||
<string name="channel_default">SAC уведомления</string>
|
<string name="channel_default">SAC уведомления</string>
|
||||||
<string name="channel_high">SAC: важные</string>
|
<string name="channel_high">SAC: важные</string>
|
||||||
<string name="channel_critical">SAC: критические</string>
|
<string name="channel_critical">SAC: критические</string>
|
||||||
|
<string name="channel_default_silent">SAC уведомления (без звука)</string>
|
||||||
|
<string name="channel_high_silent">SAC: важные (без звука)</string>
|
||||||
|
<string name="channel_critical_silent">SAC: критические (без звука)</string>
|
||||||
|
<string name="notification_mode_title">Push-уведомления</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user