refactor(system): decompose SystemScreen into focused section components
Extract Host, Portal, Curator, Gateway, Credentials, Operations, Checkpoints, ShellHooks, ActionLog, and HookCreateDialog into components/.
This commit is contained in:
1 parent
5ecc2e7d88
commit
35dc778702
12 files changed
+1794
-1507
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val ActionButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
||||
|
||||
@Composable
|
||||
fun ActionButtonContent(
|
||||
icon: ImageVector?,
|
||||
text: String,
|
||||
iconTint: Color = LocalContentColor.current,
|
||||
textColor: Color = Color.Unspecified,
|
||||
loading: Boolean = false,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else if (icon != null) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = iconTint)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||
|
||||
fun LazyListScope.actionLogSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
viewModel: SystemViewModel,
|
||||
) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.system_action_log_title),
|
||||
trailing = {
|
||||
TextButton(onClick = { viewModel.closeActionLog() }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
// Action title
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text(
|
||||
text = state.activeAction ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val actionLog = state.actionLog
|
||||
if (actionLog != null) {
|
||||
if (actionLog.running == true) {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.system_action_log_running),
|
||||
status = StatusBadgeType.INFO,
|
||||
)
|
||||
} else {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.system_action_log_done),
|
||||
status = StatusBadgeType.SUCCESS,
|
||||
)
|
||||
actionLog.exit_code?.let { code ->
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.system_action_log_exit, code),
|
||||
status =
|
||||
if (code == 0) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.system_action_log_starting),
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Output lines
|
||||
state.actionLog?.lines?.let { lines ->
|
||||
if (lines.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.sm)) {
|
||||
lines.take(20).forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (lines.size > 20) {
|
||||
Text(
|
||||
text = stringResource(R.string.system_more_lines, lines.size - 20),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.InfoRow
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.util.formatBytes
|
||||
|
||||
fun LazyListScope.checkpointsSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
onPruneConfirm: (Boolean) -> Unit,
|
||||
) {
|
||||
state.checkpoints?.let { checkpoints ->
|
||||
item {
|
||||
SectionHeader(title = stringResource(R.string.system_sec_checkpoints))
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
val sessionsCount = checkpoints.sessions?.size ?: 0
|
||||
val totalBytes = checkpoints.total_bytes ?: 0L
|
||||
InfoRow(
|
||||
label = "",
|
||||
value =
|
||||
stringResource(
|
||||
R.string.system_checkpoints_summary,
|
||||
sessionsCount,
|
||||
formatBytes(totalBytes),
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Button(
|
||||
onClick = { onPruneConfirm(true) },
|
||||
enabled = sessionsCount > 0,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
Text(stringResource(R.string.system_checkpoints_prune), maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||
|
||||
fun LazyListScope.credentialsSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
viewModel: SystemViewModel,
|
||||
onRemoveRequest: (Pair<String, Int>) -> Unit,
|
||||
) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.system_sec_credentials),
|
||||
trailing = {
|
||||
val hasCreds = state.credentials.any { it.entries?.isNotEmpty() == true }
|
||||
if (hasCreds) {
|
||||
Text(
|
||||
text = "${state.credentials.sumOf { it.entries?.size ?: 0 }} key(s)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
// Add credential form
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.credProvider,
|
||||
onValueChange = viewModel::updateCredProvider,
|
||||
label = { Text(stringResource(R.string.system_credentials_provider)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.credKey,
|
||||
onValueChange = viewModel::updateCredKey,
|
||||
label = { Text(stringResource(R.string.system_credentials_api_key)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.credLabel,
|
||||
onValueChange = viewModel::updateCredLabel,
|
||||
label = { Text(stringResource(R.string.system_credentials_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Button(
|
||||
onClick = { viewModel.addCredential() },
|
||||
enabled = !state.addingCred && state.credKey.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.addingCred) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(spacing.sm))
|
||||
} else {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
}
|
||||
Text(stringResource(R.string.system_credentials_add_key), maxLines = 1, softWrap = false)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
|
||||
// Provider list with entries
|
||||
if (state.credentials.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.system_credentials_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = spacing.sm),
|
||||
)
|
||||
}
|
||||
|
||||
state.credentials.forEach { provider ->
|
||||
Text(
|
||||
text = provider.provider ?: "?",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(vertical = spacing.xs),
|
||||
)
|
||||
provider.entries?.forEach { entry ->
|
||||
CredentialEntryRow(
|
||||
entry = entry,
|
||||
providerName = provider.provider ?: "",
|
||||
onRemove = { onRemoveRequest(Pair(provider.provider ?: "", entry.index ?: 0)) },
|
||||
spacing = spacing,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.InfoRow
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||
|
||||
fun LazyListScope.curatorSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
viewModel: SystemViewModel,
|
||||
) {
|
||||
state.curator?.let { curator ->
|
||||
item {
|
||||
SectionHeader(title = stringResource(R.string.system_sec_curator))
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
// Status
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
val statusText: String
|
||||
val statusType: StatusBadgeType
|
||||
when {
|
||||
curator.enabled != true -> {
|
||||
statusText = stringResource(R.string.system_status_disabled)
|
||||
statusType = StatusBadgeType.ERROR
|
||||
}
|
||||
|
||||
curator.paused == true -> {
|
||||
statusText = stringResource(R.string.system_status_paused)
|
||||
statusType = StatusBadgeType.WARNING
|
||||
}
|
||||
|
||||
else -> {
|
||||
statusText = stringResource(R.string.system_status_active)
|
||||
statusType = StatusBadgeType.SUCCESS
|
||||
}
|
||||
}
|
||||
StatusBadge(text = statusText, status = statusType)
|
||||
}
|
||||
|
||||
// Interval info
|
||||
curator.interval_hours?.let { hrs ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_curator_interval, hrs),
|
||||
value = "",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
}
|
||||
|
||||
// Last run
|
||||
curator.last_run_at?.let { lastRun ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_curator_last_run, lastRun),
|
||||
value = "",
|
||||
)
|
||||
} ?: InfoRow(
|
||||
label = stringResource(R.string.system_curator_never_run),
|
||||
value = "",
|
||||
)
|
||||
|
||||
// Actions
|
||||
if (curator.enabled == true) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.toggleCuratorPaused() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
if (curator.paused == true) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.PlayArrow,
|
||||
text = stringResource(R.string.system_curator_resume),
|
||||
)
|
||||
} else {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Pause,
|
||||
text = stringResource(R.string.system_curator_pause),
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.runCuratorNow() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Refresh,
|
||||
text = stringResource(R.string.system_curator_run_now),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.InfoRow
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||
|
||||
fun LazyListScope.gatewaySection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
viewModel: SystemViewModel,
|
||||
) {
|
||||
state.status?.let { status ->
|
||||
item {
|
||||
SectionHeader(title = stringResource(R.string.system_sec_gateway))
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
// Status badge + version
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
val isRunning = status.gateway_running == true
|
||||
StatusBadge(
|
||||
text =
|
||||
if (isRunning) {
|
||||
stringResource(R.string.system_status_running)
|
||||
} else {
|
||||
stringResource(R.string.system_status_stopped)
|
||||
},
|
||||
status = if (isRunning) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Version · Sessions · PID
|
||||
status.version?.let { ver ->
|
||||
val parts = mutableListOf(ver)
|
||||
status.active_sessions?.let { parts.add("$it session(s)") }
|
||||
state.doctorReport?.let { report ->
|
||||
if (report.ok && report.pid != null) {
|
||||
parts.add("pid ${report.pid}")
|
||||
}
|
||||
}
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_label_hermes_version),
|
||||
value = parts.joinToString(" · "),
|
||||
)
|
||||
}
|
||||
|
||||
// Auth required
|
||||
status.auth_required?.let { auth ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_auth_required),
|
||||
value = if (auth) "yes" else "no",
|
||||
)
|
||||
}
|
||||
|
||||
// Active platforms
|
||||
status.gateway_platforms?.let { platforms ->
|
||||
if (platforms.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
platforms.forEach { (name, platform) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Devices,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
platform?.state?.let { platformState ->
|
||||
StatusBadge(
|
||||
text = platformState,
|
||||
status =
|
||||
when (platformState.lowercase()) {
|
||||
"connected", "running", "ready" -> StatusBadgeType.SUCCESS
|
||||
"error", "disconnected", "failed" -> StatusBadgeType.ERROR
|
||||
else -> StatusBadgeType.NEUTRAL
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
val isRunning = status.gateway_running == true
|
||||
if (!isRunning) {
|
||||
Button(
|
||||
onClick = { viewModel.startGateway() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.PlayArrow,
|
||||
text = stringResource(R.string.system_gateway_start),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isRunning) {
|
||||
Button(
|
||||
onClick = { viewModel.restartGateway() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.RestartAlt,
|
||||
text = stringResource(R.string.system_gateway_restart),
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.stopGateway() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Stop,
|
||||
text = stringResource(R.string.system_gateway_stop),
|
||||
iconTint = MaterialTheme.colorScheme.error,
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
|
||||
@Composable
|
||||
fun HookCreateDialog(
|
||||
isOpen: Boolean,
|
||||
event: String,
|
||||
command: String,
|
||||
matcher: String,
|
||||
timeout: String,
|
||||
approve: Boolean,
|
||||
isCreating: Boolean,
|
||||
onEventChange: (String) -> Unit,
|
||||
onCommandChange: (String) -> Unit,
|
||||
onMatcherChange: (String) -> Unit,
|
||||
onTimeoutChange: (String) -> Unit,
|
||||
onApproveChange: (Boolean) -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (!isOpen) return
|
||||
val spacing = LocalSpacing.current
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.system_hooks_modal_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = event,
|
||||
onValueChange = onEventChange,
|
||||
label = { Text(stringResource(R.string.system_hooks_field_event)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = command,
|
||||
onValueChange = onCommandChange,
|
||||
label = { Text(stringResource(R.string.system_hooks_field_command)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = matcher,
|
||||
onValueChange = onMatcherChange,
|
||||
label = { Text(stringResource(R.string.system_hooks_field_matcher)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = timeout,
|
||||
onValueChange = onTimeoutChange,
|
||||
label = { Text(stringResource(R.string.system_hooks_field_timeout)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = approve,
|
||||
onCheckedChange = onApproveChange,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.system_hooks_field_approve),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.system_hooks_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onCreate,
|
||||
enabled = !isCreating && command.isNotBlank(),
|
||||
) {
|
||||
if (isCreating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(spacing.sm))
|
||||
}
|
||||
Text(stringResource(R.string.system_hooks_create))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.system_confirm_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Update
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.InfoRow
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.common.StatCard
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||
import com.m57.hermescontrol.util.formatBytes
|
||||
import com.m57.hermescontrol.util.formatDuration
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun LazyListScope.hostSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
viewModel: SystemViewModel,
|
||||
) {
|
||||
state.stats?.let { stats ->
|
||||
item {
|
||||
SectionHeader(title = stringResource(R.string.system_sec_host))
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
// OS, Arch, Hostname
|
||||
stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) }
|
||||
stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) }
|
||||
stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) }
|
||||
stats.python_version?.let { python ->
|
||||
val impl = stats.python_impl?.let { " ($it)" } ?: ""
|
||||
InfoRow(stringResource(R.string.system_label_python), "$python$impl")
|
||||
}
|
||||
|
||||
// Hermes version + update badge
|
||||
stats.hermes_version?.let { ver ->
|
||||
val updateBadge =
|
||||
state.updateInfo?.let { info ->
|
||||
when {
|
||||
info.update_available == true && info.behind != null && info.behind > 0 -> {
|
||||
" (${stringResource(
|
||||
R.string.system_version_update_available,
|
||||
)}: ${stringResource(R.string.system_version_behind, info.behind)})"
|
||||
}
|
||||
|
||||
info.update_available == true -> {
|
||||
" (${stringResource(
|
||||
R.string.system_version_update_available,
|
||||
)})"
|
||||
}
|
||||
|
||||
else -> {
|
||||
" (${stringResource(R.string.system_version_latest)})"
|
||||
}
|
||||
}
|
||||
} ?: ""
|
||||
InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge")
|
||||
}
|
||||
|
||||
// Update buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = spacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.checkForUpdate(false) },
|
||||
enabled = !state.checkingUpdate,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
if (state.checkingUpdate) {
|
||||
ActionButtonContent(
|
||||
icon = null,
|
||||
text = stringResource(R.string.system_action_check_updates),
|
||||
loading = true,
|
||||
)
|
||||
} else {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Update,
|
||||
text = stringResource(R.string.system_action_check_updates),
|
||||
)
|
||||
}
|
||||
}
|
||||
state.updateInfo?.let { info ->
|
||||
if (info.update_available == true && info.can_apply == true) {
|
||||
Button(
|
||||
onClick = { viewModel.openUpdateConfirm() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.PlayArrow,
|
||||
text = stringResource(R.string.system_action_update_now),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
|
||||
// CPU / Memory / Disk stat cards
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
stats.cpu_percent?.let { cpu ->
|
||||
StatCard(
|
||||
label = stringResource(R.string.system_label_cpu),
|
||||
value = "${cpu.toInt()}%",
|
||||
icon = Icons.Filled.Speed,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
stats.memory?.percent?.let { mem ->
|
||||
StatCard(
|
||||
label = stringResource(R.string.system_label_memory),
|
||||
value = "${mem.toInt()}%",
|
||||
icon = Icons.Filled.Memory,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
stats.disk?.percent?.let { disk ->
|
||||
StatCard(
|
||||
label = stringResource(R.string.system_label_disk),
|
||||
value = "${disk.toInt()}%",
|
||||
icon = Icons.Filled.Storage,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory details
|
||||
stats.memory?.let { mem ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_label_memory),
|
||||
value =
|
||||
stringResource(
|
||||
R.string.system_label_used_total_percent,
|
||||
formatBytes(mem.used ?: 0L),
|
||||
formatBytes(mem.total ?: 0L),
|
||||
"${(mem.percent ?: 0.0).toInt()}",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Disk details
|
||||
stats.disk?.let { disk ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_label_disk),
|
||||
value =
|
||||
stringResource(
|
||||
R.string.system_label_used_total_percent,
|
||||
formatBytes(disk.used ?: 0L),
|
||||
formatBytes(disk.total ?: 0L),
|
||||
"${(disk.percent ?: 0.0).toInt()}",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Uptime
|
||||
stats.uptime_seconds?.let { secs ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_label_uptime),
|
||||
value = formatDuration(secs),
|
||||
)
|
||||
}
|
||||
|
||||
// Load avg
|
||||
stats.load_avg?.let { loads ->
|
||||
if (loads.isNotEmpty()) {
|
||||
val coresText =
|
||||
stats.cpu_count?.let {
|
||||
" ($it ${stringResource(
|
||||
R.string.system_label_cores,
|
||||
)})"
|
||||
} ?: ""
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_label_load_avg),
|
||||
value = loads.joinToString(", ") { "%.2f".format(it) } + coresText,
|
||||
)
|
||||
}
|
||||
} ?: stats.cpu_count?.let { cores ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_label_cpu),
|
||||
value = "$cores ${stringResource(R.string.system_label_cores)}",
|
||||
)
|
||||
}
|
||||
|
||||
// psutil warning
|
||||
if (stats.psutil == false) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.system_psutil_warning),
|
||||
status = StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.HealthAndSafety
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||
|
||||
fun LazyListScope.operationsSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
viewModel: SystemViewModel,
|
||||
onImportConfirm: (Boolean) -> Unit,
|
||||
onImportPick: () -> Unit,
|
||||
downloadBackup: () -> Unit,
|
||||
) {
|
||||
item {
|
||||
SectionHeader(title = stringResource(R.string.system_sec_operations))
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
// Operation buttons row 1
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.loadAll() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.HealthAndSafety,
|
||||
text = stringResource(R.string.system_op_doctor),
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.runSecurityAudit() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.VerifiedUser,
|
||||
text = stringResource(R.string.system_op_security_audit),
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.runUpdateSkills() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Refresh,
|
||||
text = stringResource(R.string.system_op_update_skills),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
// Operation buttons row 2
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.runPromptSize() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.HealthAndSafety,
|
||||
text = stringResource(R.string.system_op_prompt_size),
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.runDump() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Build,
|
||||
text = stringResource(R.string.system_op_dump),
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.runConfigMigrate() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Build,
|
||||
text = stringResource(R.string.system_op_config_migrate),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
|
||||
// Backup section
|
||||
Text(
|
||||
text = stringResource(R.string.system_sec_backup),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Button(
|
||||
onClick = { viewModel.triggerBackup() },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
ActionButtonContent(
|
||||
icon = Icons.Filled.Backup,
|
||||
text = stringResource(R.string.system_op_backup_create),
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = downloadBackup,
|
||||
enabled = state.backupArchive != null && !state.isDownloading,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
if (state.isDownloading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Filled.CloudDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.system_op_backup_download),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore from picked archive (issue #786: SAF picker → upload)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.system_sec_restore),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onImportPick,
|
||||
enabled = !state.isImporting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.system_op_restore_pick),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
|
||||
state.importFileName?.let { name ->
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
Button(
|
||||
onClick = { onImportConfirm(true) },
|
||||
enabled = state.importFileName != null && !state.isImporting,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isImporting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onError,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
} else {
|
||||
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
}
|
||||
Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
|
||||
// Debug share section
|
||||
SectionHeader(title = stringResource(R.string.system_sec_debug_share))
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.system_debug_share_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
Switch(
|
||||
checked = state.shareRedact,
|
||||
onCheckedChange = { viewModel.toggleShareRedact() },
|
||||
)
|
||||
Spacer(modifier = Modifier.size(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.system_debug_share_redact),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = spacing.xs),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.runDebugShare() },
|
||||
enabled = !state.sharing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.sharing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(spacing.sm))
|
||||
} else {
|
||||
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
}
|
||||
Text(
|
||||
if (state.sharing) {
|
||||
stringResource(R.string.system_debug_share_uploading)
|
||||
} else {
|
||||
stringResource(R.string.system_debug_share_generate)
|
||||
},
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
|
||||
// Debug share results
|
||||
state.debugShare?.let { share ->
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
StatusBadge(
|
||||
text =
|
||||
if (share.ok == true) {
|
||||
stringResource(R.string.system_debug_share_uploaded)
|
||||
} else {
|
||||
stringResource(R.string.system_debug_share_failed)
|
||||
},
|
||||
status = if (share.ok == true) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
|
||||
)
|
||||
share.redacted?.let { redacted ->
|
||||
StatusBadge(
|
||||
text =
|
||||
if (redacted) {
|
||||
stringResource(R.string.system_debug_share_redacted)
|
||||
} else {
|
||||
stringResource(R.string.system_debug_share_not_redacted)
|
||||
},
|
||||
status = StatusBadgeType.INFO,
|
||||
)
|
||||
}
|
||||
share.auto_delete_seconds?.let { secs ->
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.system_debug_share_auto_delete, secs / 3600),
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
share.urls?.forEach { (name, url) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "$name: $url",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = {
|
||||
@Suppress("DEPRECATION")
|
||||
clipboardManager.setText(AnnotatedString(url))
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Filled.ContentCopy,
|
||||
contentDescription = stringResource(R.string.system_debug_share_copy_all),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.InfoRow
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
|
||||
fun LazyListScope.portalSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
uriHandler: UriHandler,
|
||||
) {
|
||||
state.portal?.let { portal ->
|
||||
item {
|
||||
SectionHeader(title = stringResource(R.string.system_sec_portal))
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
// Login status
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
val loggedIn = portal.logged_in == true
|
||||
StatusBadge(
|
||||
text =
|
||||
if (loggedIn) {
|
||||
stringResource(R.string.system_status_logged_in)
|
||||
} else {
|
||||
stringResource(R.string.system_status_not_logged_in)
|
||||
},
|
||||
status = if (loggedIn) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
|
||||
)
|
||||
if (!loggedIn) {
|
||||
Text(
|
||||
text = stringResource(R.string.system_portal_login_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Provider
|
||||
portal.provider?.let { provider ->
|
||||
InfoRow(
|
||||
label = stringResource(R.string.system_portal_provider, provider),
|
||||
value = "",
|
||||
)
|
||||
}
|
||||
|
||||
// Manage subscription link
|
||||
portal.subscription_url?.let { url ->
|
||||
TextButton(
|
||||
onClick = { uriHandler.openUri(url) },
|
||||
modifier = Modifier.padding(top = spacing.xs),
|
||||
) {
|
||||
Icon(Icons.Filled.Link, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
Text(
|
||||
stringResource(R.string.system_portal_manage_subscription),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Feature routing
|
||||
portal.features?.let { features ->
|
||||
if (features.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
features.forEach { feature ->
|
||||
InfoRow(
|
||||
label = feature.label ?: stringResource(R.string.system_portal_feature_routing),
|
||||
value = feature.state ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.m57.hermescontrol.ui.system.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.system.SystemUiState
|
||||
import com.m57.hermescontrol.ui.system.SystemViewModel
|
||||
|
||||
fun LazyListScope.shellHooksSection(
|
||||
state: SystemUiState,
|
||||
spacing: Spacing,
|
||||
viewModel: SystemViewModel,
|
||||
) {
|
||||
val hooks = state.hooks?.hooks ?: emptyList()
|
||||
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.system_sec_hooks),
|
||||
trailing = {
|
||||
FilledTonalButton(onClick = { viewModel.toggleHookModal() }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.size(spacing.xs))
|
||||
Text(stringResource(R.string.system_hooks_new), maxLines = 1, softWrap = false)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (hooks.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.system_hooks_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(hooks, key = { "${it.event}:${it.command}" }) { hook ->
|
||||
HookCard(hook = hook, spacing = spacing, onDelete = {
|
||||
viewModel.deleteHook(hook.event ?: "", hook.command ?: "")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user