feat: биометрия при старте, обновление обзора, фильтры и UI (0.5.0)

Блокировка UI до отпечатка на cold start и resume, автообновление дашборда,
фильтр событий по хосту, вкладка событий на экране хоста, контраст карточек
и навигация стрелками вместо текстовых кнопок.
This commit is contained in:
PTah
2026-06-14 15:22:28 +10:00
parent 50b97354a4
commit 6af2a96c38
6 changed files with 415 additions and 72 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 = 13 versionCode = 14
versionName = "0.4.9" versionName = "0.5.0"
} }
buildTypes { buildTypes {
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Computer import androidx.compose.material.icons.filled.Computer
@@ -16,7 +17,9 @@ import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -24,13 +27,13 @@ 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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateOf as composeMutableStateOf import androidx.compose.runtime.mutableStateOf as composeMutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
@@ -60,8 +63,8 @@ import ru.kalinamall.seaca.ui.theme.SeacaTheme
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
private lateinit var repo: SacRepository private lateinit var repo: SacRepository
private var needsBiometric = false private val biometricUnlocked = mutableStateOf(true)
private var biometricPassed = true private var pendingBiometric = false
private val deepLinkState = composeMutableStateOf<String?>(null) private val deepLinkState = composeMutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -69,12 +72,17 @@ class MainActivity : FragmentActivity() {
repo = SacRepository(applicationContext) repo = SacRepository(applicationContext)
requestNotificationPermission() requestNotificationPermission()
deepLinkState.value = extractDeepLink(intent) deepLinkState.value = extractDeepLink(intent)
if (shouldRequireBiometric()) {
biometricUnlocked.value = false
pendingBiometric = true
}
setContent { setContent {
val unlocked by biometricUnlocked
SeacaApp( SeacaApp(
repo = repo, repo = repo,
deepLink = deepLinkState.value, deepLink = deepLinkState.value,
onDeepLinkConsumed = { deepLinkState.value = null }, onDeepLinkConsumed = { deepLinkState.value = null },
biometricUnlocked = biometricPassed || !needsBiometric, biometricUnlocked = unlocked,
) )
} }
} }
@@ -87,26 +95,27 @@ class MainActivity : FragmentActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (repo.session.getAccessToken() != null) { if (shouldRequireBiometric()) {
needsBiometric = true biometricUnlocked.value = false
biometricPassed = false pendingBiometric = true
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (needsBiometric && repo.session.getAccessToken() != null && !biometricPassed) { if (pendingBiometric && !biometricUnlocked.value && repo.session.getAccessToken() != null) {
pendingBiometric = false
BiometricGate.authenticate( BiometricGate.authenticate(
activity = this, activity = this,
onSuccess = { onSuccess = { biometricUnlocked.value = true },
biometricPassed = true
needsBiometric = false
},
onFailure = { finish() }, onFailure = { finish() },
) )
} }
} }
private fun shouldRequireBiometric(): Boolean =
repo.session.getAccessToken() != null && BiometricGate.canAuthenticate(this)
private fun extractDeepLink(intent: Intent?): String? { private fun extractDeepLink(intent: Intent?): String? {
intent?.getStringExtra("deep_link")?.let { return it } intent?.getStringExtra("deep_link")?.let { return it }
val uri: Uri = intent?.data ?: return null val uri: Uri = intent?.data ?: return null
@@ -165,6 +174,7 @@ private fun SeacaApp(
role == "operator" || role == "admin" role == "operator" || role == "admin"
} }
var mainTabIndex by rememberSaveable { mutableIntStateOf(MainTab.Dashboard.ordinal) } var mainTabIndex by rememberSaveable { mutableIntStateOf(MainTab.Dashboard.ordinal) }
var dashboardRefreshTick by rememberSaveable { mutableIntStateOf(0) }
val mainTab = MainTab.entries[mainTabIndex.coerceIn(MainTab.entries.indices)] val mainTab = MainTab.entries[mainTabIndex.coerceIn(MainTab.entries.indices)]
SeacaTheme { SeacaTheme {
@@ -179,7 +189,12 @@ private fun SeacaApp(
} }
if (!biometricUnlocked) { if (!biometricUnlocked) {
Text("Подтвердите доступ…") Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
return@SeacaTheme return@SeacaTheme
} }
@@ -188,7 +203,13 @@ private fun SeacaApp(
MainShell( MainShell(
repo = repo, repo = repo,
selectedTab = mainTab, selectedTab = mainTab,
onTabSelected = { mainTabIndex = it.ordinal }, dashboardRefreshTick = dashboardRefreshTick,
onTabSelected = { tab ->
mainTabIndex = tab.ordinal
if (tab == MainTab.Dashboard) {
dashboardRefreshTick++
}
},
onOpenEvent = { nav.navigate("event/$it") }, onOpenEvent = { nav.navigate("event/$it") },
onOpenProblem = { nav.navigate("problem/$it") }, onOpenProblem = { nav.navigate("problem/$it") },
onOpenHost = { nav.navigate("host/$it") }, onOpenHost = { nav.navigate("host/$it") },
@@ -217,7 +238,12 @@ private fun SeacaApp(
"host/{id}", "host/{id}",
arguments = listOf(navArgument("id") { type = NavType.LongType }), arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry -> ) { entry ->
HostDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() } HostDetailScreen(
repo = repo,
id = entry.arguments?.getLong("id") ?: 0L,
onOpenEvent = { nav.navigate("event/$it") },
onBack = { nav.popBackStack() },
)
} }
composable("device") { composable("device") {
DeviceScreen(repo) { nav.popBackStack("main", false) } DeviceScreen(repo) { nav.popBackStack("main", false) }
@@ -238,6 +264,7 @@ private enum class MainTab(val label: String) {
private fun MainShell( private fun MainShell(
repo: SacRepository, repo: SacRepository,
selectedTab: MainTab, selectedTab: MainTab,
dashboardRefreshTick: Int,
onTabSelected: (MainTab) -> Unit, onTabSelected: (MainTab) -> Unit,
onOpenEvent: (Long) -> Unit, onOpenEvent: (Long) -> Unit,
onOpenProblem: (Long) -> Unit, onOpenProblem: (Long) -> Unit,
@@ -277,7 +304,7 @@ private fun MainShell(
) { padding -> ) { padding ->
Box(Modifier.padding(padding)) { Box(Modifier.padding(padding)) {
when (selectedTab) { when (selectedTab) {
MainTab.Dashboard -> DashboardScreen(repo) MainTab.Dashboard -> DashboardScreen(repo, refreshKey = dashboardRefreshTick)
MainTab.Events -> EventsScreen(repo, onOpenEvent) MainTab.Events -> EventsScreen(repo, onOpenEvent)
MainTab.Problems -> ProblemsScreen(repo, onOpenProblem) MainTab.Problems -> ProblemsScreen(repo, onOpenProblem)
MainTab.Hosts -> HostsScreen(repo, onOpenHost) MainTab.Hosts -> HostsScreen(repo, onOpenHost)
@@ -0,0 +1,48 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun BackArrowButton(
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад",
tint = if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
@Composable
fun ForwardArrowButton(
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Вперёд",
tint = if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
@@ -1,5 +1,6 @@
package ru.kalinamall.seaca.ui.screens package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -7,18 +8,26 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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
@@ -29,18 +38,26 @@ 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.R
import ru.kalinamall.seaca.data.EventSummary
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.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.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
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.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
private enum class HostDetailTab(val label: String) {
Info("Информация"),
Events("События"),
}
@Composable @Composable
fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var data by remember { mutableStateOf<EventDetail?>(null) } var data by remember { mutableStateOf<EventDetail?>(null) }
@@ -173,19 +190,25 @@ fun ProblemDetailScreen(
} }
} }
if (d.events.isNotEmpty()) { if (d.events.isNotEmpty()) {
Text("Связанные события", style = MaterialTheme.typography.titleMedium) Text("Связанные события", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
d.events.forEach { e -> d.events.forEach { e ->
Text("${e.severity} · ${e.title}\n${e.occurredAt}", style = MaterialTheme.typography.bodySmall) Text("${e.severity} · ${e.title}\n${e.occurredAt}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }
} }
@Composable @Composable
fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { fun HostDetailScreen(
repo: SacRepository,
id: Long,
onOpenEvent: (Long) -> Unit,
onBack: () -> Unit,
) {
var data by remember { mutableStateOf<HostDetail?>(null) } var data by remember { mutableStateOf<HostDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var selectedTab by remember { mutableIntStateOf(HostDetailTab.Info.ordinal) }
LaunchedEffect(id) { LaunchedEffect(id) {
loading = true loading = true
@@ -198,14 +221,75 @@ fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
} }
} }
DetailFrame(title = data?.hostname ?: "Хост", loading, error, onBack) { Column(
val d = data ?: return@DetailFrame Modifier
Field("Продукт", d.product) .fillMaxSize()
Field("Версия", d.productVersion ?: "") .navigationBarsPadding(),
Field("ОС", "${d.osFamily} ${d.osVersion.orEmpty()}".trim()) ) {
Field("Агент", d.agentStatus) Row(
Field("Последний контакт", d.lastSeenAt) Modifier
d.inventory?.let { inv -> .fillMaxWidth()
.padding(start = 4.dp, end = 16.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = onBack)
Text(
data?.hostname ?: "Хост",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 4.dp),
)
}
error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (loading) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
} else {
val host = data ?: return@Column
TabRow(selectedTabIndex = selectedTab) {
HostDetailTab.entries.forEachIndexed { index, tab ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(tab.label) },
)
}
}
when (HostDetailTab.entries[selectedTab]) {
HostDetailTab.Info -> HostInfoContent(host)
HostDetailTab.Events -> HostEventsContent(
repo = repo,
hostname = host.hostname,
onOpenEvent = onOpenEvent,
)
}
}
}
}
@Composable
private fun HostInfoContent(host: HostDetail) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Field("Продукт", host.product)
Field("Версия", host.productVersion ?: "")
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
Field("Агент", host.agentStatus)
Field("Последний контакт", host.lastSeenAt)
host.inventory?.let { inv ->
val lines = inventoryFieldLines(inv) val lines = inventoryFieldLines(inv)
if (lines.isEmpty()) { if (lines.isEmpty()) {
Field("Inventory", "") Field("Inventory", "")
@@ -217,6 +301,96 @@ fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
} }
} }
@Composable
private fun HostEventsContent(
repo: SacRepository,
hostname: String,
onOpenEvent: (Long) -> Unit,
) {
var page by remember { mutableIntStateOf(1) }
var items by remember { mutableStateOf<List<EventSummary>>(emptyList()) }
var total by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
fun load(showRefreshIndicator: Boolean = false) {
scope.launch {
if (showRefreshIndicator) refreshing = true else loading = true
error = null
try {
val res = repo.withAuth {
it.listEvents(page = page, pageSize = 30, hostname = hostname)
}
items = res.items
total = res.total
} catch (e: Exception) {
error = e.message
} finally {
loading = false
refreshing = false
}
}
}
LaunchedEffect(page, hostname) { load() }
Column(
Modifier
.fillMaxSize()
.padding(16.dp),
) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
if (items.isEmpty() && loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else {
LazyColumn {
items(items, key = { it.id }) { event ->
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onOpenEvent(event.id) },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp)) {
Text(
"${event.severity} · ${event.title}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
event.occurredAt,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = { if (page > 1) page-- }, enabled = page > 1)
Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface)
ForwardArrowButton(onClick = { page++ }, enabled = page * 30 < total)
}
}
}
@Composable @Composable
fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
var baseUrl by remember { mutableStateOf<String?>(null) } var baseUrl by remember { mutableStateOf<String?>(null) }
@@ -313,11 +487,18 @@ private fun DetailFrame(
.navigationBarsPadding(), .navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Button(onClick = onBack) { Text("Назад") } Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.titleLarge) BackArrowButton(onClick = onBack)
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 4.dp),
)
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (loading) { if (loading) {
CircularProgressIndicator() CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else { } else {
content() content()
} }
@@ -9,16 +9,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -30,17 +30,19 @@ 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.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.kalinamall.seaca.data.DashboardSummary import ru.kalinamall.seaca.data.DashboardSummary
import ru.kalinamall.seaca.data.EventListResponse import ru.kalinamall.seaca.data.EventListResponse
import ru.kalinamall.seaca.data.EventSummary import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.HostListResponse
import ru.kalinamall.seaca.data.HostSummary import ru.kalinamall.seaca.data.HostSummary
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
import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
private val REPORT_TYPE_OPTIONS = listOf( private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов", "report.daily.ssh" to "Отчёты ssh клиентов",
@@ -49,15 +51,20 @@ private val REPORT_TYPE_OPTIONS = listOf(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DashboardScreen(repo: SacRepository) { fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
var data by remember { mutableStateOf<DashboardSummary?>(null) } var data by remember { mutableStateOf<DashboardSummary?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(false) }
var refreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
fun load() { fun load(showRefreshIndicator: Boolean = false) {
scope.launch { scope.launch {
if (showRefreshIndicator) {
refreshing = true
} else {
loading = true loading = true
}
error = null error = null
try { try {
data = repo.withAuth { it.dashboardSummary() } data = repo.withAuth { it.dashboardSummary() }
@@ -65,20 +72,34 @@ fun DashboardScreen(repo: SacRepository) {
error = e.message error = e.message
} finally { } finally {
loading = false loading = false
refreshing = false
} }
} }
} }
LaunchedEffect(Unit) { load() } LaunchedEffect(refreshKey) { load() }
PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.fillMaxSize()) { LifecycleResumeEffect(refreshKey) {
load(showRefreshIndicator = data != null)
onPauseOrDispose { }
}
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.fillMaxSize(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
data?.let { d -> data?.let { d ->
StatCard("События 24ч", d.eventsLast24h.toString()) StatCard("События 24ч", d.eventsLast24h.toString())
StatCard("Проблемы open", d.problemsOpen.toString()) StatCard("Проблемы open", d.problemsOpen.toString())
StatCard("Хосты", "${d.hostsTotal} (stale: ${d.hostsStale})") StatCard("Хосты", "${d.hostsTotal} (stale: ${d.hostsStale})")
Text("Последние события", style = MaterialTheme.typography.titleMedium) Text(
"Последние события",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
d.recentEvents.take(8).forEach { e -> d.recentEvents.take(8).forEach { e ->
Text( Text(
dashboardRecentEventLine(e), dashboardRecentEventLine(e),
@@ -88,7 +109,7 @@ fun DashboardScreen(repo: SacRepository) {
} }
} }
if (data == null && loading) { if (data == null && loading) {
CircularProgressIndicator() CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} }
} }
} }
@@ -105,10 +126,16 @@ private fun dashboardRecentEventLine(event: EventSummary): String {
@Composable @Composable
private fun StatCard(label: String, value: String) { private fun StatCard(label: String, value: String) {
Card(Modifier.fillMaxWidth()) { Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium) Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.headlineSmall) Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface)
} }
} }
} }
@@ -116,10 +143,19 @@ private fun StatCard(label: String, value: String) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var hostname by remember { mutableStateOf("") }
PagedListScreen( PagedListScreen(
title = "События", title = "События",
loadPage = { page, severity -> loadPage = { page, severity, host ->
val res = repo.withAuth { it.listEvents(page = page, pageSize = 30, severity = severity) } val res = repo.withAuth {
it.listEvents(
page = page,
pageSize = 30,
severity = severity,
hostname = host,
)
}
res.items to res.total res.items to res.total
}, },
itemKey = EventSummary::id, itemKey = EventSummary::id,
@@ -127,6 +163,17 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
itemSub = { "${it.hostname} · ${it.occurredAt}" }, itemSub = { "${it.hostname} · ${it.occurredAt}" },
onOpen = onOpen, onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"), severities = listOf(null, "info", "warning", "high", "critical"),
hostnameFilter = hostname,
headerContent = {
OutlinedTextField(
value = hostname,
onValueChange = { hostname = it },
label = { Text("Хост") },
placeholder = { Text("Фильтр по имени хоста") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
},
) )
} }
@@ -135,7 +182,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen( PagedListScreen(
title = "Проблемы", title = "Проблемы",
loadPage = { page, filter -> loadPage = { page, filter, _ ->
val res = repo.withAuth { val res = repo.withAuth {
it.listProblems(page = page, pageSize = 30, status = filter ?: "open") it.listProblems(page = page, pageSize = 30, status = filter ?: "open")
} }
@@ -154,7 +201,7 @@ fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
fun HostsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { fun HostsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen( PagedListScreen(
title = "Хосты", title = "Хосты",
loadPage = { page, _ -> loadPage = { page, _, _ ->
val res = repo.withAuth { it.listHosts(page = page, pageSize = 30) } val res = repo.withAuth { it.listHosts(page = page, pageSize = 30) }
res.items to res.total res.items to res.total
}, },
@@ -174,18 +221,20 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var page by remember { mutableIntStateOf(1) } var page by remember { mutableIntStateOf(1) }
var data by remember { mutableStateOf<EventListResponse?>(null) } var data by remember { mutableStateOf<EventListResponse?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
fun load() { fun load(showRefreshIndicator: Boolean = false) {
scope.launch { scope.launch {
loading = true if (showRefreshIndicator) refreshing = true else loading = true
try { try {
data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) } data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) }
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
} finally { } finally {
loading = false loading = false
refreshing = false
} }
} }
} }
@@ -216,7 +265,11 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
} }
} }
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) { PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
LazyColumn { LazyColumn {
items(data?.items.orEmpty(), key = { it.id }) { item -> items(data?.items.orEmpty(), key = { it.id }) { item ->
ListRow( ListRow(
@@ -235,41 +288,51 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
@Composable @Composable
private fun <T> PagedListScreen( private fun <T> PagedListScreen(
title: String, title: String,
loadPage: suspend (Int, String?) -> Pair<List<T>, Int>, loadPage: suspend (Int, String?, String?) -> Pair<List<T>, Int>,
itemKey: (T) -> Long, itemKey: (T) -> Long,
itemLabel: (T) -> String, itemLabel: (T) -> String,
itemSub: (T) -> String, itemSub: (T) -> String,
onOpen: (Long) -> Unit, onOpen: (Long) -> Unit,
severities: List<String?>, severities: List<String?>,
filterLabel: String = "Severity", filterLabel: String = "Severity",
hostnameFilter: String = "",
headerContent: @Composable () -> Unit = {},
) { ) {
var page by remember { mutableIntStateOf(1) } var page by remember { mutableIntStateOf(1) }
var filter by remember { mutableStateOf(severities.firstOrNull()) } var filter by remember { mutableStateOf(severities.firstOrNull()) }
var items by remember { mutableStateOf<List<T>>(emptyList()) } var items by remember { mutableStateOf<List<T>>(emptyList()) }
var total by remember { mutableIntStateOf(0) } var total by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val hostQuery = hostnameFilter.trim().ifBlank { null }
fun load() { fun load(showRefreshIndicator: Boolean = false) {
scope.launch { scope.launch {
loading = true if (showRefreshIndicator) refreshing = true else loading = true
try { try {
val (pageItems, pageTotal) = loadPage(page, filter) val (pageItems, pageTotal) = loadPage(page, filter, hostQuery)
items = pageItems items = pageItems
total = pageTotal total = pageTotal
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
} finally { } finally {
loading = false loading = false
refreshing = false
} }
} }
} }
LaunchedEffect(page, filter) { load() } LaunchedEffect(hostQuery) {
if (page != 1) page = 1
}
LaunchedEffect(page, filter, hostQuery) { load() }
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge) Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
headerContent()
if (severities.isNotEmpty()) { if (severities.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
severities.forEach { s -> severities.forEach { s ->
@@ -282,7 +345,11 @@ private fun <T> PagedListScreen(
} }
} }
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) { PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { load(showRefreshIndicator = true) },
modifier = Modifier.weight(1f),
) {
LazyColumn { LazyColumn {
items(items, key = { itemKey(it) }) { item -> items(items, key = { itemKey(it) }) { item ->
ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(item)) } ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(item)) }
@@ -300,19 +367,27 @@ private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) {
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.clickable(onClick = onClick), .clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) { ) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge) Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
Text(subtitle, style = MaterialTheme.typography.bodySmall) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }
@Composable @Composable
private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) { private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(
Button(onClick = onPrev, enabled = page > 1) { Text("Назад") } Modifier.fillMaxWidth(),
Text("Стр. $page · всего $total") horizontalArrangement = Arrangement.SpaceBetween,
Button(onClick = onNext, enabled = page * pageSize < total) { Text("Вперёд") } verticalAlignment = Alignment.CenterVertically,
) {
BackArrowButton(onClick = onPrev, enabled = page > 1)
Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface)
ForwardArrowButton(onClick = onNext, enabled = page * pageSize < total)
} }
} }
@@ -9,17 +9,29 @@ import androidx.compose.ui.graphics.Color
private val SacDark = darkColorScheme( private val SacDark = darkColorScheme(
primary = Color(0xFF3DB8FF), primary = Color(0xFF3DB8FF),
onPrimary = Color(0xFF002A42),
background = Color(0xFF0F1419), background = Color(0xFF0F1419),
surface = Color(0xFF15202B),
onBackground = Color(0xFFC5D0DC), onBackground = Color(0xFFC5D0DC),
onSurface = Color(0xFFC5D0DC), surface = Color(0xFF15202B),
onSurface = Color(0xFFE8EEF4),
surfaceContainer = Color(0xFF1C2834),
surfaceContainerLow = Color(0xFF182330),
surfaceContainerHigh = Color(0xFF243240),
onSurfaceVariant = Color(0xFFB0BEC9),
error = Color(0xFFFF8A8A), error = Color(0xFFFF8A8A),
) )
private val SacLight = lightColorScheme( private val SacLight = lightColorScheme(
primary = Color(0xFF1A6FA0), primary = Color(0xFF1A6FA0),
onPrimary = Color(0xFFFFFFFF),
background = Color(0xFFF4F6F8), background = Color(0xFFF4F6F8),
onBackground = Color(0xFF1A1F24),
surface = Color(0xFFFFFFFF), surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1F24),
surfaceContainer = Color(0xFFEEF2F5),
surfaceContainerLow = Color(0xFFF4F6F8),
surfaceContainerHigh = Color(0xFFE4E9ED),
onSurfaceVariant = Color(0xFF4A5560),
) )
@Composable @Composable