diff --git a/.gitguardian.yml b/.gitguardian.yml new file mode 100644 index 00000000..78f53bc4 --- /dev/null +++ b/.gitguardian.yml @@ -0,0 +1,6 @@ +# GitGuardian configuration +# Ignore test files — they contain fixture tokens, not real secrets +paths-ignore: + - 'app/src/test/**' + - '**/*Test.kt' + - '**/*test*' diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ActionResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ActionResponse.kt new file mode 100644 index 00000000..5be6831b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ActionResponse.kt @@ -0,0 +1,12 @@ +package com.m57.hermescontrol.data.model + +data class ActionResponse( + val archive: String? = null, + val name: String? = null, + val ok: Boolean? = null, + val pid: Int? = null, + val error: String? = null, + val message: String? = null, + val uploaded_bytes: Long? = null, + val update_command: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ActionStatusResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ActionStatusResponse.kt new file mode 100644 index 00000000..0b07dc14 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ActionStatusResponse.kt @@ -0,0 +1,9 @@ +package com.m57.hermescontrol.data.model + +data class ActionStatusResponse( + val exit_code: Int? = null, + val lines: List? = null, + val name: String? = null, + val pid: Int? = null, + val running: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CheckpointsResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CheckpointsResponse.kt new file mode 100644 index 00000000..7eac4a0f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CheckpointsResponse.kt @@ -0,0 +1,12 @@ +package com.m57.hermescontrol.data.model + +data class CheckpointsResponse( + val sessions: List? = null, + val total_bytes: Long? = null, +) + +data class CheckpointSession( + val session: String? = null, + val files: Int? = null, + val bytes: Long? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolResponse.kt new file mode 100644 index 00000000..2f6e2e73 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolResponse.kt @@ -0,0 +1,18 @@ +package com.m57.hermescontrol.data.model + +data class CredentialPoolResponse( + val providers: List? = null, +) + +data class CredentialPoolProvider( + val provider: String? = null, + val entries: List? = null, +) + +data class CredentialPoolEntry( + val index: Int? = null, + val label: String? = null, + val token_preview: String? = null, + val auth_type: String? = null, + val last_status: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CuratorResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CuratorResponse.kt new file mode 100644 index 00000000..bbaf6bbe --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CuratorResponse.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +data class CuratorResponse( + val enabled: Boolean? = null, + val paused: Boolean? = null, + val interval_hours: Int? = null, + val last_run_at: String? = null, + val min_idle_hours: Int? = null, + val stale_after_days: Int? = null, + val archive_after_days: Int? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt new file mode 100644 index 00000000..2ae1c565 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt @@ -0,0 +1,9 @@ +package com.m57.hermescontrol.data.model + +data class DebugShareResponse( + val ok: Boolean? = null, + val urls: Map? = null, + val failures: List? = null, + val redacted: Boolean? = null, + val auto_delete_seconds: Long? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt new file mode 100644 index 00000000..e48a7855 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt @@ -0,0 +1,16 @@ +package com.m57.hermescontrol.data.model + +data class HookResponse( + val hooks: List? = null, + val valid_events: List? = null, +) + +data class HookEntry( + val event: String? = null, + val matcher: String? = null, + val command: String? = null, + val timeout: Int? = null, + val allowed: Boolean? = null, + val approved_at: String? = null, + val executable: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt new file mode 100644 index 00000000..273e6062 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +data class MemoryResponse( + val active: String? = null, + val builtin_files: BuiltinFileSizes? = null, +) + +data class BuiltinFileSizes( + val memory: Long? = null, + val user: Long? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PortalResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PortalResponse.kt new file mode 100644 index 00000000..f606783b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PortalResponse.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.data.model + +data class PortalResponse( + val logged_in: Boolean? = null, + val portal_url: String? = null, + val inference_url: String? = null, + val provider: String? = null, + val subscription_url: String? = null, + val features: List? = null, +) + +data class PortalFeature( + val label: String? = null, + val state: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SystemStatsResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SystemStatsResponse.kt index 71fe4ea5..3ed6f3ca 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SystemStatsResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SystemStatsResponse.kt @@ -1,24 +1,42 @@ package com.m57.hermescontrol.data.model -/** - * Wrapper around the /api/system/stats response. - * - * The payload shape can change across Hermes versions, so we keep it - * flexible with a Map and provide convenience accessors for the - * most commonly used fields. - */ data class SystemStatsResponse( - val cpu: Map? = null, - val memory: Map? = null, - val disk: Map? = null, - val uptime: Any? = null, - val extra: Map? = null, -) { - /** CPU usage percentage, if available. */ - val cpuPercent: Double? - get() = (cpu?.get("percent") as? Number)?.toDouble() + val os: String? = null, + val os_release: String? = null, + val os_version: String? = null, + val platform: String? = null, + val arch: String? = null, + val hostname: String? = null, + val python_version: String? = null, + val python_impl: String? = null, + val hermes_version: String? = null, + val cpu_count: Int? = null, + val cpu_percent: Double? = null, + val psutil: Boolean? = null, + val load_avg: List? = null, + val uptime_seconds: Double? = null, + val memory: MemoryStats? = null, + val disk: DiskStats? = null, + val process: ProcessStats? = null, +) - /** Memory usage percentage, if available. */ - val memoryPercent: Double? - get() = (memory?.get("percent") as? Number)?.toDouble() -} +data class MemoryStats( + val total: Long? = null, + val available: Long? = null, + val used: Long? = null, + val percent: Double? = null, +) + +data class DiskStats( + val total: Long? = null, + val used: Long? = null, + val free: Long? = null, + val percent: Double? = null, +) + +data class ProcessStats( + val pid: Int? = null, + val rss: Long? = null, + val create_time: Double? = null, + val num_threads: Int? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UpdateCheckResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UpdateCheckResponse.kt new file mode 100644 index 00000000..df7f8689 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UpdateCheckResponse.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +data class UpdateCheckResponse( + val install_method: String? = null, + val current_version: String? = null, + val behind: Int? = null, + val update_available: Boolean? = null, + val can_apply: Boolean? = null, + val update_command: String? = null, + val message: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 85e470f5..8c10c235 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -2,28 +2,36 @@ package com.m57.hermescontrol.data.remote import com.google.gson.JsonElement import com.m57.hermescontrol.data.model.AchievementsResponse +import com.m57.hermescontrol.data.model.ActionResponse +import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.BulkDeleteRequest +import com.m57.hermescontrol.data.model.CheckpointsResponse import com.m57.hermescontrol.data.model.ConfigSchemaResponse import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.CreateCronJobRequest import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateWebhookRequest +import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.CuratorResponse +import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate +import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse +import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.MessagingPlatformResponse import com.m57.hermescontrol.data.model.MessagingPlatformTestResult import com.m57.hermescontrol.data.model.MessagingPlatformUpdate @@ -36,6 +44,7 @@ import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingRevokeRequest import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginsHubResponse +import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.ProfileSoulResponse import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.PruneRequest @@ -56,6 +65,7 @@ import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.Toolset import com.m57.hermescontrol.data.model.ToolsetToggleRequest +import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.model.UpdateCronJobRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest @@ -64,6 +74,7 @@ import com.m57.hermescontrol.data.model.WebhookSubscription import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest import com.m57.hermescontrol.data.model.WebhooksResponse import com.m57.hermescontrol.data.model.WebhooksToggleRequest +import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -418,7 +429,7 @@ interface HermesApiService { ): Response @POST("api/ops/backup") - suspend fun triggerBackup(): Response + suspend fun triggerBackup(): Response @POST("api/ops/doctor") suspend fun runDoctor(): Response @@ -445,4 +456,117 @@ interface HermesApiService { @Query("board") board: String?, @Body task: CreateTaskBody, ): Response + + // ── Admin: Hermes update ────────────────────────────────────────── + @GET("api/hermes/update/check") + suspend fun checkHermesUpdate( + @Query("force") force: Boolean = false, + ): Response + + @POST("api/hermes/update") + suspend fun updateHermes(): Response + + // ── Admin: Portal ───────────────────────────────────────────────── + @GET("api/portal") + suspend fun getPortal(): Response + + // ── Admin: Curator ──────────────────────────────────────────────── + @GET("api/curator") + suspend fun getCurator(): Response + + @PUT("api/curator/paused") + suspend fun setCuratorPaused( + @Body body: Map, + ): Response> + + @POST("api/curator/run") + suspend fun runCurator(): Response + + // ── Admin: Memory ───────────────────────────────────────────────── + @GET("api/memory") + suspend fun getMemory(): Response + + @POST("api/memory/reset") + suspend fun resetMemory( + @Body body: Map, + ): Response> + + // ── Admin: Credential pool ──────────────────────────────────────── + @GET("api/credentials/pool") + suspend fun getCredentialPool(): Response + + @POST("api/credentials/pool") + suspend fun addCredentialPoolEntry( + @Body body: Map, + ): Response> + + @DELETE("api/credentials/pool/{provider}/{index}") + suspend fun removeCredentialPoolEntry( + @Path("provider") provider: String, + @Path("index") index: Int, + ): Response> + + // ── Admin: Operations ───────────────────────────────────────────── + @POST("api/ops/security-audit") + suspend fun runSecurityAudit(): Response + + @POST("api/ops/prompt-size") + suspend fun runPromptSize(): Response + + @POST("api/ops/dump") + suspend fun runDump(): Response + + @POST("api/ops/config-migrate") + suspend fun runConfigMigrate(): Response + + @POST("api/skills/hub/update") + suspend fun updateSkillsFromHub( + @Body body: Map = emptyMap(), + ): Response + + // ── Admin: Backup download ──────────────────────────────────────── + @GET("api/ops/backup/download") + suspend fun downloadBackup( + @Query("archive") archive: String, + ): Response + + // ── Admin: Backup import ────────────────────────────────────────── + @POST("api/ops/import") + suspend fun runImport( + @Body body: Map, + ): Response + + // ── Admin: Debug share ──────────────────────────────────────────── + @POST("api/ops/debug-share") + suspend fun runDebugShare( + @Body body: Map, + ): Response + + // ── Admin: Checkpoints ──────────────────────────────────────────── + @GET("api/ops/checkpoints") + suspend fun getCheckpoints(): Response + + @POST("api/ops/checkpoints/prune") + suspend fun pruneCheckpoints(): Response + + // ── Admin: Shell hooks ──────────────────────────────────────────── + @GET("api/ops/hooks") + suspend fun getHooks(): Response + + @POST("api/ops/hooks") + suspend fun createHook( + @Body body: Map, + ): Response> + + @DELETE("api/ops/hooks") + suspend fun deleteHook( + @Body body: Map, + ): Response> + + // ── Admin: Action status (log viewer) ──────────────────────────── + @GET("api/actions/{name}/status") + suspend fun getActionStatus( + @Path("name") name: String, + @Query("lines") lines: Int = 200, + ): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 29f67702..206404c4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -899,14 +899,14 @@ class ChatViewModel( when (result) { is NetworkResult.Success -> { val body = result.data - val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A" - val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A" + val cpuPct = body.cpu_percent?.let { String.format("%.1f%%", it) } ?: "N/A" + val memPct = body.memory?.percent?.let { String.format("%.1f%%", it) } ?: "N/A" addAssistantMessage( """ **System Resource Stats:** • **CPU Usage:** $cpuPct • **Memory Usage:** $memPct - • **Uptime:** ${body.uptime ?: "N/A"} + • **Uptime:** ${body.uptime_seconds?.let { "${it.toLong()}s" } ?: "N/A"} """.trimIndent(), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt new file mode 100644 index 00000000..72adf64e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -0,0 +1,339 @@ +package com.m57.hermescontrol.ui.connect + +import android.app.Application +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R + +@Composable +fun ConnectScreen( + onConnected: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectViewModel = + viewModel( + factory = + run { + val app = LocalContext.current.applicationContext as Application + ConnectViewModelFactory(app) + }, + ), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.connectionSuccess) { + if (state.connectionSuccess) { + onConnected() + } + } + + var passwordVisible by remember { mutableStateOf(false) } + var showManualFields by remember { mutableStateOf(false) } + + Box( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .imePadding(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Hero brand + Box( + modifier = + Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(44.dp), + ) + } + + Text( + text = stringResource(R.string.notif_title), + style = + MaterialTheme.typography.displayMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ), + ) + Text( + text = stringResource(R.string.nav_drawer_subtitle), + style = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Connection Profiles Dropdown / Selection + if (state.profiles.isNotEmpty()) { + var profilesExpanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { profilesExpanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = + state.selectedProfile?.let { p -> + stringResource(R.string.connect_selected_profile, p.name) + } ?: stringResource(R.string.connect_action_select_profile), + ) + } + DropdownMenu( + expanded = profilesExpanded, + onDismissRequest = { profilesExpanded = false }, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.connect_profile_none)) }, + onClick = { + viewModel.selectProfile(null) + profilesExpanded = false + }, + ) + state.profiles.forEach { profile -> + DropdownMenuItem( + text = { Text(profile.name) }, + onClick = { + viewModel.selectProfile(profile) + profilesExpanded = false + }, + ) + } + } + } + } + + // Pairing (primary path) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.connect_pairing_string), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + var pairingString by remember { mutableStateOf("") } + OutlinedTextField( + value = pairingString, + onValueChange = { + pairingString = it + if (it.isNotBlank()) viewModel.onPairingString(it) + }, + placeholder = { Text(stringResource(R.string.connect_placeholder_pairing)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Error message + AnimatedVisibility( + visible = state.errorMessage != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + state.errorMessage?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + // Profile Save Options prior to connecting + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = state.saveProfile, + onCheckedChange = viewModel::onSaveProfileChange, + ) + Text( + text = stringResource(R.string.connect_save_profile), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + AnimatedVisibility(visible = state.saveProfile) { + OutlinedTextField( + value = state.profileName, + onValueChange = viewModel::onProfileNameChange, + label = { Text(stringResource(R.string.connect_profile_name)) }, + placeholder = { Text(stringResource(R.string.connect_placeholder_profile_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Button( + onClick = viewModel::connect, + enabled = !state.isConnecting, + modifier = + Modifier + .fillMaxWidth() + .height(52.dp), + ) { + if (state.isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.action_connect), style = MaterialTheme.typography.labelLarge) + } + } + + TextButton( + onClick = { showManualFields = !showManualFields }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = + if (showManualFields) { + stringResource(R.string.connect_action_hide_manual) + } else { + stringResource(R.string.connect_action_show_manual) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + AnimatedVisibility(visible = showManualFields) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HorizontalDivider() + OutlinedTextField( + value = state.token, + onValueChange = viewModel::onTokenChange, + label = { Text(stringResource(R.string.settings_field_token)) }, + placeholder = { Text(stringResource(R.string.connect_placeholder_token)) }, + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = + if (passwordVisible) { + stringResource(R.string.content_desc_hide_token) + } else { + stringResource(R.string.content_desc_show_token) + }, + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.host, + onValueChange = viewModel::onHostChange, + label = { Text(stringResource(R.string.settings_field_host)) }, + placeholder = { Text(stringResource(R.string.connect_placeholder_host)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.port, + onValueChange = viewModel::onPortChange, + label = { Text(stringResource(R.string.settings_field_port)) }, + placeholder = { Text(stringResource(R.string.connect_placeholder_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 224c03e9..237bd7aa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -8,27 +8,70 @@ 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.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +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.Speed +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +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.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.CredentialPoolEntry +import com.m57.hermescontrol.data.model.HookEntry import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState @@ -43,6 +86,28 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect +// ── Helpers ───────────────────────────────────────────────────────────── + +private fun formatBytes(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" + else -> "${"%.1f".format(bytes.toDouble() / (1024 * 1024 * 1024))} GB" + } +} + +private fun formatDuration(totalSeconds: Double): String { + val days = (totalSeconds / 86400).toInt() + val hours = ((totalSeconds % 86400) / 3600).toInt() + val minutes = ((totalSeconds % 3600) / 60).toInt() + return buildString { + if (days > 0) append("${days}d ") + if (hours > 0 || days > 0) append("${hours}h ") + append("${minutes}m") + } +} + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun SystemScreen( @@ -53,32 +118,150 @@ fun SystemScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current + val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { - viewModel.loadSystemData() + viewModel.loadAll() } ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + // ── Confirmation dialogs ────────────────────────────────────────────── + + if (state.updateConfirmOpen) { + AlertDialog( + onDismissRequest = viewModel::closeUpdateConfirm, + title = { Text(stringResource(R.string.system_update_confirm_title)) }, + text = { Text(stringResource(R.string.system_update_confirm_desc)) }, + confirmButton = { + TextButton(onClick = { + viewModel.closeUpdateConfirm() + viewModel.applyUpdate() + }) { + Text(stringResource(R.string.system_confirm_update_now)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::closeUpdateConfirm) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) + } + + // Memory reset confirmation + var resetTarget by remember { mutableStateOf(null) } + if (resetTarget != null) { + AlertDialog( + onDismissRequest = { resetTarget = null }, + title = { Text(stringResource(R.string.system_memory_reset_confirm_title)) }, + text = { Text(stringResource(R.string.system_memory_reset_confirm_desc)) }, + confirmButton = { + TextButton(onClick = { + resetTarget?.let { viewModel.resetMemory(it) } + resetTarget = null + }) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = { resetTarget = null }) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) + } + + // Credential remove confirmation + var credToRemove by remember { mutableStateOf?>(null) } + if (credToRemove != null) { + AlertDialog( + onDismissRequest = { credToRemove = null }, + title = { Text(stringResource(R.string.system_credentials_remove_confirm_title)) }, + text = { Text(stringResource(R.string.system_credentials_remove_confirm_desc)) }, + confirmButton = { + TextButton(onClick = { + credToRemove?.let { (p, i) -> viewModel.removeCredential(p, i) } + credToRemove = null + }) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = { credToRemove = null }) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) + } + + // Prune checkpoints confirmation + var pruneConfirm by remember { mutableStateOf(false) } + if (pruneConfirm) { + AlertDialog( + onDismissRequest = { pruneConfirm = false }, + title = { Text(stringResource(R.string.system_checkpoints_prune_confirm_title)) }, + text = { Text(stringResource(R.string.system_checkpoints_prune_confirm_desc)) }, + confirmButton = { + TextButton(onClick = { + pruneConfirm = false + viewModel.pruneCheckpoints() + }) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = { pruneConfirm = false }) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) + } + + // Import / Restore confirmation + var importConfirm by remember { mutableStateOf(false) } + if (importConfirm) { + AlertDialog( + onDismissRequest = { importConfirm = false }, + title = { Text(stringResource(R.string.system_op_import_confirm_title)) }, + text = { Text(stringResource(R.string.system_op_import_confirm_desc)) }, + confirmButton = { + TextButton(onClick = { + importConfirm = false + viewModel.runImport(state.importPath) + }) { + Text(stringResource(R.string.system_confirm_restore)) + } + }, + dismissButton = { + TextButton(onClick = { importConfirm = false }) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) + } + HermesScaffold( + modifier = modifier, title = { Text(stringResource(R.string.screen_system)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadSystemData() }, + onRefresh = { viewModel.loadAll() }, ) { when { - state.isLoading && state.stats == null && state.doctorReport == null -> { + state.isLoading && state.stats == null && state.doctorReport == null && + state.status == null -> { LoadingState() } state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: stringResource(R.string.error_unknown), - onRetry = { viewModel.loadSystemData() }, + onRetry = { viewModel.loadAll() }, ) } - state.stats == null && state.doctorReport == null -> { + state.stats == null && state.doctorReport == null && state.status == null -> { EmptyState( title = stringResource(R.string.system_empty_title), subtitle = stringResource(R.string.system_empty_desc), @@ -95,117 +278,1465 @@ fun SystemScreen( ), verticalArrangement = Arrangement.spacedBy(spacing.sm), ) { - // Stats row - state.stats?.let { stats -> - item { - SectionHeader(title = stringResource(R.string.system_sec_performance)) - } - item { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - stats.cpuPercent?.let { cpu -> - StatCard( - label = stringResource(R.string.system_metric_cpu), - value = "$cpu%", - icon = Icons.Filled.Speed, - modifier = Modifier.weight(1f), - ) + // ── 1. Host ──────────────────────────────────────────── + hostSection(state, spacing, statusColors, viewModel) + + // ── 2. Portal ────────────────────────────────────────── + portalSection(state, spacing, uriHandler) + + // ── 3. Curator ───────────────────────────────────────── + curatorSection(state, spacing, statusColors, viewModel) + + // ── 4. Gateway ───────────────────────────────────────── + gatewaySection(state, spacing, statusColors, viewModel) + + // ── 5. Memory ────────────────────────────────────────── + memorySection(state, spacing, resetTarget, { resetTarget = it }, viewModel) + + // ── 6. Credentials ───────────────────────────────────── + credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it }) + + // ── 7. Operations ────────────────────────────────────── + operationsSection(state, spacing, viewModel, importConfirm, { importConfirm = it }) + + // ── 8. Checkpoints ───────────────────────────────────── + checkpointsSection(state, spacing, pruneConfirm, { pruneConfirm = it }) + + // ── 9. Shell Hooks ───────────────────────────────────── + shellHooksSection(state, spacing, viewModel) + + // ── 10. Action Log ───────────────────────────────────── + if (state.activeAction != null) { + actionLogSection(state, spacing, viewModel) + } + } + } + } + } +} + +// ── Section composables ───────────────────────────────────────────────── + +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +private fun LazyListScope.hostSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + statusColors: com.m57.hermescontrol.theme.HermesStatusColors, + 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)})" } - stats.memoryPercent?.let { mem -> - StatCard( - label = stringResource(R.string.system_metric_memory), - value = "$mem%", - icon = Icons.Filled.Memory, - modifier = Modifier.weight(1f), + } ?: "" + 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), + ) { + if (state.checkingUpdate) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + } else { + Icon(Icons.Filled.Update, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text(stringResource(R.string.system_action_check_updates), maxLines = 1) + } + state.updateInfo?.let { info -> + if (info.update_available == true && info.can_apply == true) { + Button( + onClick = { viewModel.openUpdateConfirm() }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp), ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_action_update_now), maxLines = 1) } } } } - // Doctor diagnostics - state.doctorReport?.let { report -> - item { - SectionHeader(title = stringResource(R.string.system_sec_doctor)) + 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), + ) } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), + 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, + ) + } + } + } + } + } +} + +private fun LazyListScope.portalSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + uriHandler: androidx.compose.ui.platform.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)) + } + } + + // 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 ?: "", + ) + } + } + } + } + } + } + } +} + +private fun LazyListScope.curatorSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + statusColors: com.m57.hermescontrol.theme.HermesStatusColors, + 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), ) { - Column(modifier = Modifier.padding(spacing.md)) { - Row( - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Icon( - imageVector = Icons.Filled.HealthAndSafety, - contentDescription = null, - tint = - if (report.ok) { - statusColors.success - } else { - statusColors.error - }, - ) + if (curator.paused == true) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_curator_resume), maxLines = 1) + } else { + Icon(Icons.Filled.Pause, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_curator_pause), maxLines = 1) + } + } + Button( + onClick = { viewModel.runCuratorNow() }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_curator_run_now), maxLines = 1) + } + } + } + } + } + } + } +} + +private fun LazyListScope.gatewaySection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + statusColors: com.m57.hermescontrol.theme.HermesStatusColors, + 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 = "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 = - if (report.ok) { - stringResource(R.string.system_status_running) - } else { - stringResource(R.string.system_status_failed) - }, + text = platformState, status = - if (report.ok) { - StatusBadgeType.SUCCESS - } else { - StatusBadgeType.ERROR + when (platformState.lowercase()) { + "connected", "running", "ready" -> StatusBadgeType.SUCCESS + "error", "disconnected", "failed" -> StatusBadgeType.ERROR + else -> StatusBadgeType.NEUTRAL }, ) } - Spacer(modifier = Modifier.height(spacing.sm)) - report.pid?.let { pid -> - InfoRow( - label = stringResource(R.string.system_info_pid), - value = pid.toString(), - ) - } - report.name?.let { name -> - InfoRow(label = stringResource(R.string.system_info_proc_name), value = name) - } } } } } - // Admin actions - item { - SectionHeader(title = stringResource(R.string.system_sec_admin)) + // 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), + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_gateway_start), maxLines = 1) + } + } + if (isRunning) { + Button( + onClick = { viewModel.restartGateway() }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Filled.RestartAlt, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_gateway_restart), maxLines = 1) + } + OutlinedButton( + onClick = { viewModel.stopGateway() }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Filled.Stop, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text( + stringResource(R.string.system_gateway_stop), + color = MaterialTheme.colorScheme.error, + maxLines = 1, + ) + } + } } - item { + } + } + } + } +} + +private fun LazyListScope.memorySection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + resetTarget: String?, + onResetRequest: (String) -> Unit, + viewModel: SystemViewModel, +) { + state.memory?.let { memory -> + item { + SectionHeader(title = stringResource(R.string.system_sec_memory)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Active provider + memory.active?.let { active -> + InfoRow( + label = stringResource(R.string.system_memory_external, active), + value = "", + ) + } ?: InfoRow( + label = stringResource(R.string.system_memory_builtin), + value = "", + ) + + // Change in Plugins link + InfoRow( + label = stringResource(R.string.system_memory_change_plugins), + value = "", + ) + + // Builtin file sizes + memory.builtin_files?.let { files -> + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + InfoRow( + label = + stringResource( + R.string.system_memory_builtin_label, + files.memory?.let { formatBytes(it) } ?: "?", + files.user?.let { formatBytes(it) } ?: "?", + ), + value = "", + ) + + // Reset buttons + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilledTonalButton( + onClick = { onResetRequest("memory") }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_memory_reset_memory), maxLines = 1) + } + FilledTonalButton( + onClick = { onResetRequest("user") }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_memory_reset_user), maxLines = 1) + } + FilledTonalButton( + onClick = { onResetRequest("all") }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Filled.DeleteForever, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_memory_reset_all), maxLines = 1) + } + } + } + } + } + } + } +} + +private fun LazyListScope.credentialsSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + viewModel: SystemViewModel, + credToRemove: Pair?, + onRemoveRequest: (Pair) -> 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) { + androidx.compose.material3.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)) + } + + 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, + ) + } + } + } + } + } +} + +@Composable +private fun CredentialEntryRow( + entry: CredentialPoolEntry, + providerName: String, + onRemove: () -> Unit, + spacing: com.m57.hermescontrol.theme.Spacing, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.label ?: "(${stringResource(R.string.system_credentials_api_key)} ${entry.index ?: 0})", + style = MaterialTheme.typography.bodyMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { + entry.token_preview?.let { preview -> + Text( + text = preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + entry.last_status?.let { status -> + StatusBadge( + text = status, + status = + when (status.lowercase()) { + "ok", "valid", "active" -> StatusBadgeType.SUCCESS + "error", "invalid", "expired" -> StatusBadgeType.ERROR + else -> StatusBadgeType.NEUTRAL + }, + ) + } + } + } + IconButton(onClick = onRemove) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } +} + +private fun LazyListScope.operationsSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + viewModel: SystemViewModel, + importConfirm: Boolean, + onImportConfirm: (Boolean) -> 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 = { + // loadAll already fetches doctor report, just call it + viewModel.loadAll() + }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.HealthAndSafety, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_doctor), maxLines = 1) + } + FilledTonalButton( + onClick = { viewModel.runSecurityAudit() }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.VerifiedUser, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_security_audit), maxLines = 1) + } + FilledTonalButton( + onClick = { viewModel.runUpdateSkills() }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_update_skills), maxLines = 1) + } + } + + 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), + ) { + Icon(Icons.Filled.HealthAndSafety, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_prompt_size), maxLines = 1) + } + FilledTonalButton( + onClick = { viewModel.runDump() }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.Build, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_dump), maxLines = 1) + } + FilledTonalButton( + onClick = { viewModel.runConfigMigrate() }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.Build, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_config_migrate), maxLines = 1) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // Backup section + Text( + text = stringResource(R.string.system_op_backup_create), + 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), + ) { + Icon(Icons.Filled.Backup, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_backup_create), maxLines = 1) + } + OutlinedButton( + onClick = { viewModel.downloadBackup() }, + enabled = state.backupArchive != null, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_op_backup_download), maxLines = 1) + } + } + + // Restore from uploaded zip + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.system_op_backup_restore_upload), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + OutlinedTextField( + value = state.importPath, + onValueChange = viewModel::updateImportPath, + label = { Text(stringResource(R.string.system_op_restore_path_label)) }, + placeholder = { Text(stringResource(R.string.system_op_restore_path)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + Button( + onClick = { onImportConfirm(true) }, + enabled = state.importPath.isNotBlank(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth(), + ) { + 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)) + } + + 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)) + + androidx.compose.material3.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) { + androidx.compose.material3.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) + }, + ) + } + + // Debug share results + state.debugShare?.let { share -> + Spacer(modifier = Modifier.height(spacing.sm)) + 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_status_stopped) + }, + 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), + ) + } + } + } + } + } + } + } +} + +private fun LazyListScope.checkpointsSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.Spacing, + pruneConfirm: Boolean, + 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)) + } + } + } + } + } +} + +private fun LazyListScope.shellHooksSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.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)) + } + }, + ) + } + + 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 ?: "") + }) + } + + // Hook creation modal + if (state.hookModalOpen) { + item { + AlertDialog( + onDismissRequest = { viewModel.toggleHookModal() }, + title = { Text(stringResource(R.string.system_hooks_modal_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { + OutlinedTextField( + value = state.hookEvent, + onValueChange = viewModel::updateHookEvent, + label = { Text(stringResource(R.string.system_hooks_field_event)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.hookCommand, + onValueChange = viewModel::updateHookCommand, + label = { Text(stringResource(R.string.system_hooks_field_command)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.hookMatcher, + onValueChange = viewModel::updateHookMatcher, + label = { Text(stringResource(R.string.system_hooks_field_matcher)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.hookTimeout, + onValueChange = viewModel::updateHookTimeout, + label = { Text(stringResource(R.string.system_hooks_field_timeout)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Switch( + checked = state.hookApprove, + onCheckedChange = viewModel::updateHookApprove, + ) + 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 = { viewModel.createHook() }, + enabled = !state.creatingHook && state.hookCommand.isNotBlank(), + ) { + if (state.creatingHook) { + androidx.compose.material3.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 = { viewModel.toggleHookModal() }) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) + } + } +} + +@Composable +private fun HookCard( + hook: HookEntry, + spacing: com.m57.hermescontrol.theme.Spacing, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Icon( + Icons.Filled.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = hook.command ?: "?", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + } + Spacer(modifier = Modifier.height(spacing.xs)) + Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { + Text( + text = hook.event ?: "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + hook.matcher?.let { matcher -> + Text( + text = stringResource(R.string.system_hooks_matcher, matcher), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(modifier = Modifier.height(spacing.xs)) + Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { + StatusBadge( + text = + if (hook.executable != false) { + stringResource(R.string.system_hooks_allowed) + } else { + stringResource(R.string.system_hooks_not_executable) + }, + status = + if (hook.executable != false) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, + ) + StatusBadge( + text = + if (hook.allowed == true) { + stringResource(R.string.system_hooks_allowed) + } else { + stringResource(R.string.system_hooks_not_approved) + }, + status = + if (hook.allowed == true) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, + ) + hook.timeout?.let { t -> + StatusBadge( + text = "${t}s", + status = StatusBadgeType.NEUTRAL, + ) + } + } + } + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +private fun LazyListScope.actionLogSection( + state: SystemUiState, + spacing: com.m57.hermescontrol.theme.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.surfaceContainer, + containerColor = MaterialTheme.colorScheme.surfaceVariant, ), ) { - Column(modifier = Modifier.padding(spacing.md)) { - Button( - onClick = { viewModel.triggerBackup() }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Filled.Backup, - contentDescription = null, - modifier = Modifier.padding(end = spacing.xs), + Column(modifier = Modifier.padding(spacing.sm)) { + lines.take(20).forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (lines.size > 20) { + Text( + text = "... ${lines.size - 20} more line(s)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text(stringResource(R.string.system_action_backup)) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 13ce996c..d31823f0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -4,27 +4,77 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.BuildConfig +import com.m57.hermescontrol.data.model.ActionResponse +import com.m57.hermescontrol.data.model.ActionStatusResponse +import com.m57.hermescontrol.data.model.CheckpointsResponse +import com.m57.hermescontrol.data.model.CredentialPoolProvider +import com.m57.hermescontrol.data.model.CredentialPoolResponse +import com.m57.hermescontrol.data.model.CuratorResponse +import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DoctorResponse +import com.m57.hermescontrol.data.model.HookResponse +import com.m57.hermescontrol.data.model.MemoryResponse +import com.m57.hermescontrol.data.model.PortalResponse +import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse +import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost -import com.m57.hermescontrol.ui.common.safeLaunchLoad import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext data class SystemUiState( val isLoading: Boolean = false, val stats: SystemStatsResponse? = null, + val portal: PortalResponse? = null, + val curator: CuratorResponse? = null, + val memory: MemoryResponse? = null, + val credentials: List = emptyList(), + val checkpoints: CheckpointsResponse? = null, + val hooks: HookResponse? = null, + val updateInfo: UpdateCheckResponse? = null, + val status: StatusResponse? = null, val doctorReport: DoctorResponse? = null, + val activeAction: String? = null, + val actionLog: ActionStatusResponse? = null, + val backupArchive: String? = null, + val downloadableBackup: String? = null, + val debugShare: DebugShareResponse? = null, val errorMessage: String? = null, val toastMessage: String? = null, + // Form states + val credProvider: String = "openrouter", + val credKey: String = "", + val credLabel: String = "", + val addingCred: Boolean = false, + // Hook modal + val hookModalOpen: Boolean = false, + val hookEvent: String = "pre_tool_call", + val hookCommand: String = "", + val hookMatcher: String = "", + val hookTimeout: String = "", + val hookApprove: Boolean = true, + val creatingHook: Boolean = false, + // Import + val importPath: String = "", + // Debug share + val shareRedact: Boolean = true, + val sharing: Boolean = false, + // Update + val checkingUpdate: Boolean = false, + val updateConfirmOpen: Boolean = false, ) class SystemViewModel : ViewModel(), ToastHost { @@ -35,56 +85,332 @@ class SystemViewModel : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(SystemUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadSystemData() { - safeLaunchLoad( - apiCall = { safeApiCall { ApiClient.hermesApi.getSystemStats() } }, - onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { data -> - _uiState.update { - it.copy( - isLoading = false, - stats = data, - ) - } - // Doctor is optional; many servers do not expose it. - loadDoctorReport() - }, - onError = { errorMsg -> - _uiState.update { - it.copy( - isLoading = false, - errorMessage = "Failed to load system stats: $errorMsg", - ) - } - }, - ) - } + private var actionPollingJob: Job? = null - private fun loadDoctorReport() { + // ── Full parallel data load ──────────────────────────────────────── + + fun loadAll() { viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.runDoctor() } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + coroutineScope { + val statsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSystemStats() } } + val statusDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } } + val portalDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPortal() } } + val curatorDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } } + val memoryDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } } + val credDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } } + val checkpointsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCheckpoints() } } + val hooksDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } } + val updateDeferred = + async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.checkHermesUpdate(false) } } + val doctorDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.runDoctor() } } + + val statsResult = statsDeferred.await() + val statusResult = statusDeferred.await() + val portalResult = portalDeferred.await() + val curatorResult = curatorDeferred.await() + val memoryResult = memoryDeferred.await() + val credResult = credDeferred.await() + val checkpointsResult = checkpointsDeferred.await() + val hooksResult = hooksDeferred.await() + val updateResult = updateDeferred.await() + val doctorResult = doctorDeferred.await() + + _uiState.update { state -> + state.copy( + isLoading = false, + stats = (statsResult as? NetworkResult.Success)?.data, + status = (statusResult as? NetworkResult.Success)?.data, + portal = (portalResult as? NetworkResult.Success)?.data, + curator = (curatorResult as? NetworkResult.Success)?.data, + memory = (memoryResult as? NetworkResult.Success)?.data, + credentials = + ( + (credResult as? NetworkResult.Success)?.data + as? CredentialPoolResponse + )?.providers ?: emptyList(), + checkpoints = (checkpointsResult as? NetworkResult.Success)?.data, + hooks = (hooksResult as? NetworkResult.Success)?.data, + updateInfo = (updateResult as? NetworkResult.Success)?.data, + doctorReport = (doctorResult as? NetworkResult.Success)?.data, + errorMessage = null, + ) + } + + // Log failures in debug builds + if (BuildConfig.DEBUG) { + listOf( + "stats" to statsResult, + "status" to statusResult, + "portal" to portalResult, + "curator" to curatorResult, + "memory" to memoryResult, + "credentials" to credResult, + "checkpoints" to checkpointsResult, + "hooks" to hooksResult, + "update" to updateResult, + "doctor" to doctorResult, + ).forEach { (name, result) -> + if (result is NetworkResult.Failure) { + Log.w(TAG, "$name endpoint: ${result.error.message}") + } + } } - if (result is NetworkResult.Success) { - _uiState.update { it.copy(doctorReport = result.data) } - } else if (result is NetworkResult.Failure && BuildConfig.DEBUG) { - Log.w(TAG, "doctor endpoint unavailable: ${result.error.message}") } } } - fun triggerBackup() { + // ── Gateway actions ──────────────────────────────────────────────── + + fun startGateway() { + runGatewayAction("start") { safeApiCall { ApiClient.hermesApi.startGateway() } } + } + + fun stopGateway() { + runGatewayAction("stop") { safeApiCall { ApiClient.hermesApi.stopGateway() } } + } + + fun restartGateway() { + runGatewayAction("restart") { safeApiCall { ApiClient.hermesApi.restartGateway() } } + } + + private fun runGatewayAction( + name: String, + apiCall: suspend () -> NetworkResult, + ) { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { apiCall() } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Gateway ${name}ed successfully") } + loadAll() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to $name gateway: ${result.error.message}") } + } + } + } + } + + // ── Update actions ───────────────────────────────────────────────── + + fun checkForUpdate(force: Boolean) { + _uiState.update { it.copy(checkingUpdate = true) } viewModelScope.launch { val result = withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.triggerBackup() } + safeApiCall { ApiClient.hermesApi.checkHermesUpdate(force) } } when (result) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Backup triggered successfully") } + _uiState.update { it.copy(checkingUpdate = false, updateInfo = result.data) } } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + checkingUpdate = false, + toastMessage = "Update check failed: ${result.error.message}", + ) + } + } + } + } + } + fun applyUpdate() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.updateHermes() } }, + label = "Update", + ) + } + + fun openUpdateConfirm() { + _uiState.update { it.copy(updateConfirmOpen = true) } + } + + fun closeUpdateConfirm() { + _uiState.update { it.copy(updateConfirmOpen = false) } + } + + // ── Curator actions ──────────────────────────────────────────────── + + fun toggleCuratorPaused() { + val currentlyPaused = _uiState.value.curator?.paused ?: return + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.setCuratorPaused(mapOf("paused" to !currentlyPaused)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Curator ${if (!currentlyPaused) "paused" else "resumed"}", + ) + } + loadAll() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to toggle curator: ${result.error.message}") } + } + } + } + } + + fun runCuratorNow() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.runCurator() } }, + label = "Curator run", + ) + } + + // ── Memory actions ───────────────────────────────────────────────── + + fun resetMemory(target: String) { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") } + loadAll() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to reset memory: ${result.error.message}") } + } + } + } + } + + // ── Credential pool actions ──────────────────────────────────────── + + fun updateCredProvider(v: String) { + _uiState.update { it.copy(credProvider = v) } + } + + fun updateCredKey(v: String) { + _uiState.update { it.copy(credKey = v) } + } + + fun updateCredLabel(v: String) { + _uiState.update { it.copy(credLabel = v) } + } + + fun addCredential() { + val state = _uiState.value + if (state.credKey.isBlank()) { + _uiState.update { it.copy(toastMessage = "Key/token is required") } + return + } + _uiState.update { it.copy(addingCred = true) } + viewModelScope.launch { + val body = + buildMap { + put("provider", state.credProvider) + put("token", state.credKey) + if (state.credLabel.isNotBlank()) put("label", state.credLabel) + } + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.addCredentialPoolEntry(body) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + addingCred = false, + credKey = "", + credLabel = "", + toastMessage = "Credential added", + ) + } + loadAll() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy(addingCred = false, toastMessage = "Failed to add credential: ${result.error.message}") + } + } + } + } + } + + fun removeCredential( + provider: String, + index: Int, + ) { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.removeCredentialPoolEntry(provider, index) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Credential removed") } + loadAll() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to remove credential: ${result.error.message}") } + } + } + } + } + + // ── Operation actions ────────────────────────────────────────────── + + fun runSecurityAudit() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.runSecurityAudit() } }, + label = "Security audit", + ) + } + + fun runPromptSize() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.runPromptSize() } }, + label = "Prompt size check", + ) + } + + fun runDump() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.runDump() } }, + label = "Dump", + ) + } + + fun runConfigMigrate() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.runConfigMigrate() } }, + label = "Config migrate", + ) + } + + fun runUpdateSkills() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } }, + label = "Skills update", + ) + } + + // ── Backup actions ───────────────────────────────────────────────── + + fun triggerBackup() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.triggerBackup() } } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Backup triggered", + backupArchive = result.data?.archive, + ) + } + loadAll() + } is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${result.error.message}") } } @@ -92,7 +418,248 @@ class SystemViewModel : ViewModel(), ToastHost { } } + fun downloadBackup() { + val archive = + _uiState.value.backupArchive ?: run { + _uiState.update { it.copy(toastMessage = "No backup archive available") } + return + } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.downloadBackup(archive) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Backup downloaded") } + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to download backup: ${result.error.message}") } + } + } + } + } + + fun updateImportPath(v: String) { + _uiState.update { it.copy(importPath = v) } + } + + fun runImport(path: String) { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.runImport(mapOf("path" to path)) } }, + label = "Import", + ) + } + + // ── Debug share actions ──────────────────────────────────────────── + + fun toggleShareRedact() { + _uiState.update { it.copy(shareRedact = !it.shareRedact) } + } + + fun runDebugShare() { + val shareRedact = _uiState.value.shareRedact + _uiState.update { it.copy(sharing = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.runDebugShare(mapOf("redact" to shareRedact)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + sharing = false, + debugShare = result.data, + toastMessage = "Debug share created", + ) + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + sharing = false, + toastMessage = "Debug share failed: ${result.error.message}", + ) + } + } + } + } + } + + // ── Checkpoints actions ──────────────────────────────────────────── + + fun pruneCheckpoints() { + runOperation( + apiCall = { safeApiCall { ApiClient.hermesApi.pruneCheckpoints() } }, + label = "Checkpoint prune", + ) + } + + // ── Hook actions ─────────────────────────────────────────────────── + + fun toggleHookModal() { + _uiState.update { it.copy(hookModalOpen = !it.hookModalOpen) } + } + + fun updateHookEvent(v: String) { + _uiState.update { it.copy(hookEvent = v) } + } + + fun updateHookCommand(v: String) { + _uiState.update { it.copy(hookCommand = v) } + } + + fun updateHookMatcher(v: String) { + _uiState.update { it.copy(hookMatcher = v) } + } + + fun updateHookTimeout(v: String) { + _uiState.update { it.copy(hookTimeout = v) } + } + + fun updateHookApprove(v: Boolean) { + _uiState.update { it.copy(hookApprove = v) } + } + + fun createHook() { + val state = _uiState.value + if (state.hookCommand.isBlank()) { + _uiState.update { it.copy(toastMessage = "Command is required") } + return + } + _uiState.update { it.copy(creatingHook = true) } + viewModelScope.launch { + val body = + buildMap { + put("event", state.hookEvent) + put("command", state.hookCommand) + if (state.hookMatcher.isNotBlank()) put("matcher", state.hookMatcher) + if (state.hookTimeout.isNotBlank()) put("timeout", state.hookTimeout.toIntOrNull() ?: 30) + put("allowed", state.hookApprove) + } + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.createHook(body) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + creatingHook = false, + hookModalOpen = false, + hookCommand = "", + hookMatcher = "", + hookTimeout = "", + toastMessage = "Hook created", + ) + } + loadAll() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + creatingHook = false, + toastMessage = "Failed to create hook: ${result.error.message}", + ) + } + } + } + } + } + + fun deleteHook( + event: String, + command: String, + ) { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.deleteHook(mapOf("event" to event, "command" to command)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Hook deleted") } + loadAll() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to delete hook: ${result.error.message}") } + } + } + } + } + + // ── Action log polling ───────────────────────────────────────────── + + private fun pollActionStatus(name: String) { + actionPollingJob?.cancel() + _uiState.update { it.copy(activeAction = name, actionLog = null) } + actionPollingJob = + viewModelScope.launch { + while (isActive) { + delay(1200) + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getActionStatus(name) } + } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(actionLog = result.data) } + if (result.data.running != true) { + loadAll() + break + } + } + } + } + } + + fun closeActionLog() { + actionPollingJob?.cancel() + _uiState.update { it.copy(activeAction = null, actionLog = null) } + } + + // ── Helpers ──────────────────────────────────────────────────────── + + private fun runOperation( + apiCall: suspend () -> NetworkResult, + label: String, + ) { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { apiCall() } + when (result) { + is NetworkResult.Success -> { + result.data?.name?.let { pollActionStatus(it) } + _uiState.update { it.copy(toastMessage = "$label started") } + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "$label failed: ${result.error.message}") } + } + } + } + } + + // ── Transient state ──────────────────────────────────────────────── + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } + + fun clearTransientState() { + actionPollingJob?.cancel() + _uiState.update { + it.copy( + errorMessage = null, + toastMessage = null, + activeAction = null, + actionLog = null, + isLoading = false, + addingCred = false, + creatingHook = false, + sharing = false, + checkingUpdate = false, + updateConfirmOpen = false, + hookModalOpen = false, + ) + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce479921..cbe84631 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -264,16 +264,153 @@ No system data Tap refresh to load system diagnostics. - Performance - CPU Usage - Memory - Doctor + + + Host + OS + Arch + Host + Python + Hermes + CPU + Memory + Disk + Uptime + Load avg + cores + %1$s / %2$s + %1$s / %2$s (%3$s%%) + Install the psutil extra for CPU / memory / disk metrics. + latest + update available + %1$d behind + Check for updates + Update now + Update Hermes? + This will run hermes update and restart the gateway. + + + Nous Portal + logged in + not logged in + inference provider: %1$s + Manage subscription + Tool Gateway routing + Log in with hermes portal. + + + Skill curator + active + paused + disabled + every %1$dh + last run %1$s + never run + Resume + Pause + Run now + + + Gateway Running - Failed - Process PID - Process Name - Administration - Trigger System Backup + Stopped + Start + Restart + Stop + + + Memory + External provider: %1$s + built-in only + Change in Plugins + New credentials: hermes memory setup + Built-in files — MEMORY.md: %1$s · USER.md: %2$s + Reset MEMORY.md + Reset USER.md + Reset all + Reset memory + This permanently erases the selected built-in memory files. This cannot be undone. + + + Credential pool + Provider + API key + Label + Add key + No pooled credentials. Add one above to enable key rotation. + Remove credential + Remove this pooled API key? The agent will no longer rotate through it. + + + Operations + Run doctor + Security audit + Update skills + Prompt size + Support dump + Migrate config + Create backup + Download backup + Restore from backup upload + Choose restore zip + Restore upload + Restore from backups path + Restore path + No backup created yet + No backup archive selected + Restore full Hermes backup? + This will overwrite your current Hermes configuration, skills, sessions, and data. This cannot be undone. + + + Debug share + Uploads system info + logs to a public paste service and returns links to send the Hermes team. Pastes auto-delete after 6 hours. + Generate share link + Uploading… + Redact credential-shaped tokens before upload (recommended) + uploaded + redacted + not redacted + auto-deletes in %1$dh + Copy all + + + Checkpoints + %1$d session(s) · %2$s + Prune + Prune checkpoints + Delete the rollback checkpoint shadow store? Existing /rollback points will be lost. + + + Shell hooks + New hook + No shell hooks configured. + matcher: %1$s + not executable + allowed + not approved + Remove shell hook + Remove this hook from config and revoke its consent? It stops firing on the next restart. + New shell hook + Event + Command (absolute path) + Matcher (optional) + Timeout (s) + Approve now (grant consent so it fires; otherwise it stays configured but inactive) + Shell hooks run arbitrary commands on this host. Only add scripts you trust. Takes effect on the next gateway/session restart. + Create hook + Creating + + + Action log + running + done + exit %1$d + Starting… + + + Cancel + Restore + Update now No model providers reported diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index b6702d70..3a103fa5 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -4,11 +4,16 @@ import android.app.Application import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse +import com.m57.hermescontrol.data.model.CheckpointsResponse +import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.EnvVarConfig +import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse @@ -16,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.MainModelAssignment +import com.m57.hermescontrol.data.model.MemoryResponse +import com.m57.hermescontrol.data.model.MemoryStats import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformResponse import com.m57.hermescontrol.data.model.MoaConfigResponse @@ -24,6 +31,7 @@ import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginsHubResponse +import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.SessionInfo @@ -32,6 +40,7 @@ import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.ToggleSkillRequest +import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.ui.channels.ChannelsViewModel @@ -66,8 +75,8 @@ class E2eIntegrationTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - val testMainDispatcher = Dispatchers.Main mockkStatic(Dispatchers::class) + val testMainDispatcher = Dispatchers.Main every { Dispatchers.IO } returns testDispatcher every { Dispatchers.Main } returns testMainDispatcher @@ -966,32 +975,43 @@ class E2eIntegrationTest { @Test fun testSystemDiagnostics_success() = - runTest { - val stats = SystemStatsResponse(cpu = mapOf("percent" to 50.0), memory = mapOf("percent" to 60.0)) + runTest(testDispatcher) { + val stats = + SystemStatsResponse( + cpu_percent = 50.0, + memory = MemoryStats(total = 8192, used = 4096, percent = 60.0), + ) val doctor = DoctorResponse(true, 24856, "doctor") coEvery { mockApiService.getSystemStats() } returns Response.success(stats) coEvery { mockApiService.runDoctor() } returns Response.success(doctor) - coEvery { mockApiService.triggerBackup() } returns Response.success(Unit) + coEvery { mockApiService.getStatus() } returns + Response.success(StatusResponse("1.0", true, 0, false, null)) + coEvery { mockApiService.getPortal() } returns + Response.success(PortalResponse(logged_in = false)) + coEvery { mockApiService.getCurator() } returns Response.success(CuratorResponse()) + coEvery { mockApiService.getMemory() } returns Response.success(MemoryResponse()) + coEvery { mockApiService.getCredentialPool() } returns + Response.success(CredentialPoolResponse()) + coEvery { mockApiService.getCheckpoints() } returns + Response.success(CheckpointsResponse()) + coEvery { mockApiService.getHooks() } returns Response.success(HookResponse()) + coEvery { mockApiService.checkHermesUpdate(false) } returns + Response.success(UpdateCheckResponse()) + coEvery { mockApiService.triggerBackup() } returns Response.success(ActionResponse()) val viewModel = SystemViewModel() - viewModel.loadSystemData() + viewModel.loadAll() advanceUntilIdle() assertEquals( 50.0, - viewModel.uiState.value.stats - ?.cpuPercent, - ) - assertEquals( - true, - viewModel.uiState.value.doctorReport - ?.ok, + viewModel.uiState.value.stats?.cpu_percent, ) viewModel.triggerBackup() advanceUntilIdle() - assertEquals("Backup triggered successfully", viewModel.uiState.value.toastMessage) + assertEquals("Backup triggered", viewModel.uiState.value.toastMessage) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index a77dc038..f278ee47 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class ModelSerializationTest { @@ -371,30 +372,49 @@ class ModelSerializationTest { } @Test - fun testSystemStatsResponse_cpuPercentTypeSafety() { - val jsonNumber = """{"cpu":{"percent":45.2}}""" - val responseNumber = gson.fromJson(jsonNumber, SystemStatsResponse::class.java) - assertEquals(45.2, responseNumber.cpuPercent) - - val jsonStringNum = """{"cpu":{"percent":"45.2"}}""" - val responseStringNum = gson.fromJson(jsonStringNum, SystemStatsResponse::class.java) - assertNull(responseStringNum.cpuPercent) - - val jsonNullPercent = """{"cpu":{"percent":null}}""" - val responseNullPercent = gson.fromJson(jsonNullPercent, SystemStatsResponse::class.java) - assertNull(responseNullPercent.cpuPercent) - - val jsonMissingPercent = """{"cpu":{}}""" - val responseMissingPercent = gson.fromJson(jsonMissingPercent, SystemStatsResponse::class.java) - assertNull(responseMissingPercent.cpuPercent) - - val jsonNullCpu = """{"cpu":null}""" - val responseNullCpu = gson.fromJson(jsonNullCpu, SystemStatsResponse::class.java) - assertNull(responseNullCpu.cpuPercent) - - val jsonMissingCpu = "{}" - val responseMissingCpu = gson.fromJson(jsonMissingCpu, SystemStatsResponse::class.java) - assertNull(responseMissingCpu.cpuPercent) + fun testSystemStatsResponse_deserialization() { + val json = + """ + { + "os": "Linux", + "os_release": "5.4.274", + "arch": "aarch64", + "hostname": "hermes-box", + "python_version": "3.12.0", + "python_impl": "CPython", + "hermes_version": "1.2.3", + "cpu_count": 8, + "cpu_percent": 45.2, + "psutil": true, + "load_avg": [1.5, 1.2, 0.9], + "uptime_seconds": 86400.0, + "memory": { + "total": 8388608, + "available": 4194304, + "used": 4194304, + "percent": 50.0 + }, + "disk": { + "total": 1073741824, + "used": 536870912, + "free": 536870912, + "percent": 50.0 + } + } + """.trimIndent() + val response = gson.fromJson(json, SystemStatsResponse::class.java) + assertEquals("Linux", response.os) + assertEquals("aarch64", response.arch) + assertEquals("hermes-box", response.hostname) + assertEquals("1.2.3", response.hermes_version) + assertEquals(45.2, response.cpu_percent) + assertEquals(8, response.cpu_count) + assertTrue(response.psutil ?: false) + assertEquals(1.5, response.load_avg?.get(0) ?: 0.0, 0.01) + assertEquals(86400.0, response.uptime_seconds ?: 0.0, 0.01) + assertEquals(50.0, response.memory?.percent ?: 0.0, 0.01) + assertEquals(8388608, response.memory?.total) + assertEquals(50.0, response.disk?.percent ?: 0.0, 0.01) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt index 67f71c08..06689fcb 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt @@ -317,40 +317,7 @@ class HermesApiServiceTest { assertTrue(response.isSuccessful) val stats = response.body() assertNotNull(stats) - assertNull(stats?.cpuPercent) - assertNull(stats?.memoryPercent) - } - - @Test - fun testGetSystemStats_nonNumericPercent_returnsNull() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"cpu":{"percent":"not_a_number"}}"""), - ) - - val response = apiService.getSystemStats() - assertTrue(response.isSuccessful) - val stats = response.body() - assertNotNull(stats) - assertNull(stats?.cpuPercent) - } - - @Test - fun testGetSystemStats_typeMismatchArray_behavior() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"cpu": []}"""), - ) - - val response = apiService.getSystemStats() - assertTrue(response.isSuccessful) - val stats = response.body() - assertNotNull(stats) - // Gson deserializes an empty array [] into an empty map {} for Map fields - assertTrue(stats?.cpu?.isEmpty() == true) + assertNull(stats?.cpu_percent) + assertNull(stats?.memory?.percent) } }