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