feat: биометрия при старте, обновление обзора, фильтры и UI (0.5.0)
Блокировка UI до отпечатка на cold start и resume, автообновление дашборда, фильтр событий по хосту, вкладка событий на экране хоста, контраст карточек и навигация стрелками вместо текстовых кнопок.
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 = 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 {
|
||||||
loading = true
|
if (showRefreshIndicator) {
|
||||||
|
refreshing = true
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user