* feat(#434): add system tab model files * feat(#434): add system API endpoints * feat(#434): rewrite SystemViewModel with full parity * feat(#434): add system screen UI and string resources * feat(#434): update tests for new SystemStatsResponse & ViewModel * fix(#434): fix compile errors — nullable Boolean, SmallFilledTonalButton -> FilledTonalButton * fix(#434): fix old SystemStatsResponse refs in ChatViewModel, wrap sections in item{} blocks * fix(#434): rename sections to lowercase, fix LazyListScope extensions * fix(#434): wrap hook dialog in item{}, pass spacing param to actionLogSection * fix(#434): update HermesApiServiceTest for new typed SystemStatsResponse * fix(#434): fix E2E test coroutine timing, remove outdated Gson edge-case test * fix(#434): fix JUnit 4 assertNotNull argument order * fix(#434): simplify HermesApiServiceTest, restore all mocks in E2e test * fix(#434): remove Gson-edge-case test, fix E2E test dispatcher wiring - Removed testGetSystemStats_nonNumericPercent_returnsNull: tests Gson type-coercion behavior, not app logic — throws JsonSyntaxException - Fixed E2eIntegrationTest dispatcher: mock Dispatchers.IO with answers {} that returns the current Dispatchers.Main dynamically, so runTest's per-test dispatcher override is honored by both viewModelScope.launch and async(Dispatchers.IO) blocks → advanceUntilIdle() advances all work - Removed Dispatchers.Main static mock so runTest can override it * fix(#434): fix E2E test dispatcher for SystemViewModel - Removed Dispatchers.setMain + testDispatcher field from setUp - runTest handles Dispatchers.Main automatically — don't override it - Mock Dispatchers.IO with answers { Dispatchers.Main } so it dynamically resolves to whatever Main is at call time (runTest's TestScope dispatcher) - This ensures viewModelScope.launch and async(Dispatchers.IO) both run on the same TestScope dispatcher, making advanceUntilIdle() effective for all work * fix(#434): use runTest(testDispatcher) for SystemViewModel test The SystemViewModel.loadAll() uses async(Dispatchers.IO) inside coroutineScope { }. With the mocked Dispatchers setup, both Main and IO return testDispatcher, but runTest()'s default TestScope uses a different dispatcher instance whose advanceUntilIdle() can't advance testDispatcher's scheduler. Fix: pass testDispatcher to runTest() so the TestScope's scheduler matches testDispatcher's scheduler, making advanceUntilIdle() effective for all async work. * fix(#434): align test assertion with toast message change The SystemViewModel.triggerBackup() toast message was changed from 'Backup triggered successfully' to 'Backup triggered', but the E2eIntegrationTest assertion wasn't updated, causing a ComparisonFailure on the unit-tests CI job. * fix(#434): address review issues - Fix #1: Change triggerBackup() return type from Unit to ActionResponse so backupArchive is captured, enabling the download button - Fix #2: Replace deprecated LocalClipboardManager with LocalClipboard suspend API (removes CI deprecation warning) - Fix #3: Consolidate redundant 'Hermes version' InfoRow in gateway section (sessions + PID shown in one row) - Remove orphaned system_gateway_state_pid string resource - Fix import ordering for ktlint compliance * fix(#434): revert LocalClipboard change, use @Suppress('DEPRECATION') instead LocalClipboard.setText is not available in the Compose version used by the project. Reverting to LocalClipboardManager with a suppress annotation to keep the deprecation warning clean without breaking compilation. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> Co-authored-by: Hermes Agent <hermes@agent.local>
This commit is contained in:
21 files changed
+3083
-230
No files matched your search
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class ActionStatusResponse(
|
||||
val exit_code: Int? = null,
|
||||
val lines: List<String>? = null,
|
||||
val name: String? = null,
|
||||
val pid: Int? = null,
|
||||
val running: Boolean? = null,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class CheckpointsResponse(
|
||||
val sessions: List<CheckpointSession>? = null,
|
||||
val total_bytes: Long? = null,
|
||||
)
|
||||
|
||||
data class CheckpointSession(
|
||||
val session: String? = null,
|
||||
val files: Int? = null,
|
||||
val bytes: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class CredentialPoolResponse(
|
||||
val providers: List<CredentialPoolProvider>? = null,
|
||||
)
|
||||
|
||||
data class CredentialPoolProvider(
|
||||
val provider: String? = null,
|
||||
val entries: List<CredentialPoolEntry>? = 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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class DebugShareResponse(
|
||||
val ok: Boolean? = null,
|
||||
val urls: Map<String, String>? = null,
|
||||
val failures: List<String>? = null,
|
||||
val redacted: Boolean? = null,
|
||||
val auto_delete_seconds: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class HookResponse(
|
||||
val hooks: List<HookEntry>? = null,
|
||||
val valid_events: List<String>? = 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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<PortalFeature>? = null,
|
||||
)
|
||||
|
||||
data class PortalFeature(
|
||||
val label: String? = null,
|
||||
val state: String? = null,
|
||||
)
|
||||
@@ -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<String, Any?>? = null,
|
||||
val memory: Map<String, Any?>? = null,
|
||||
val disk: Map<String, Any?>? = null,
|
||||
val uptime: Any? = null,
|
||||
val extra: Map<String, Any?>? = 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<Double>? = 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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<EnvVarRevealResponse>
|
||||
|
||||
@POST("api/ops/backup")
|
||||
suspend fun triggerBackup(): Response<Unit>
|
||||
suspend fun triggerBackup(): Response<ActionResponse>
|
||||
|
||||
@POST("api/ops/doctor")
|
||||
suspend fun runDoctor(): Response<DoctorResponse>
|
||||
@@ -445,4 +456,117 @@ interface HermesApiService {
|
||||
@Query("board") board: String?,
|
||||
@Body task: CreateTaskBody,
|
||||
): Response<KanbanTask>
|
||||
|
||||
// ── Admin: Hermes update ──────────────────────────────────────────
|
||||
@GET("api/hermes/update/check")
|
||||
suspend fun checkHermesUpdate(
|
||||
@Query("force") force: Boolean = false,
|
||||
): Response<UpdateCheckResponse>
|
||||
|
||||
@POST("api/hermes/update")
|
||||
suspend fun updateHermes(): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Portal ─────────────────────────────────────────────────
|
||||
@GET("api/portal")
|
||||
suspend fun getPortal(): Response<PortalResponse>
|
||||
|
||||
// ── Admin: Curator ────────────────────────────────────────────────
|
||||
@GET("api/curator")
|
||||
suspend fun getCurator(): Response<CuratorResponse>
|
||||
|
||||
@PUT("api/curator/paused")
|
||||
suspend fun setCuratorPaused(
|
||||
@Body body: Map<String, Boolean>,
|
||||
): Response<Map<String, Any>>
|
||||
|
||||
@POST("api/curator/run")
|
||||
suspend fun runCurator(): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Memory ─────────────────────────────────────────────────
|
||||
@GET("api/memory")
|
||||
suspend fun getMemory(): Response<MemoryResponse>
|
||||
|
||||
@POST("api/memory/reset")
|
||||
suspend fun resetMemory(
|
||||
@Body body: Map<String, String>,
|
||||
): Response<Map<String, Any>>
|
||||
|
||||
// ── Admin: Credential pool ────────────────────────────────────────
|
||||
@GET("api/credentials/pool")
|
||||
suspend fun getCredentialPool(): Response<CredentialPoolResponse>
|
||||
|
||||
@POST("api/credentials/pool")
|
||||
suspend fun addCredentialPoolEntry(
|
||||
@Body body: Map<String, String>,
|
||||
): Response<Map<String, Any>>
|
||||
|
||||
@DELETE("api/credentials/pool/{provider}/{index}")
|
||||
suspend fun removeCredentialPoolEntry(
|
||||
@Path("provider") provider: String,
|
||||
@Path("index") index: Int,
|
||||
): Response<Map<String, Any>>
|
||||
|
||||
// ── Admin: Operations ─────────────────────────────────────────────
|
||||
@POST("api/ops/security-audit")
|
||||
suspend fun runSecurityAudit(): Response<ActionResponse>
|
||||
|
||||
@POST("api/ops/prompt-size")
|
||||
suspend fun runPromptSize(): Response<ActionResponse>
|
||||
|
||||
@POST("api/ops/dump")
|
||||
suspend fun runDump(): Response<ActionResponse>
|
||||
|
||||
@POST("api/ops/config-migrate")
|
||||
suspend fun runConfigMigrate(): Response<ActionResponse>
|
||||
|
||||
@POST("api/skills/hub/update")
|
||||
suspend fun updateSkillsFromHub(
|
||||
@Body body: Map<String, String> = emptyMap(),
|
||||
): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Backup download ────────────────────────────────────────
|
||||
@GET("api/ops/backup/download")
|
||||
suspend fun downloadBackup(
|
||||
@Query("archive") archive: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
// ── Admin: Backup import ──────────────────────────────────────────
|
||||
@POST("api/ops/import")
|
||||
suspend fun runImport(
|
||||
@Body body: Map<String, Any>,
|
||||
): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Debug share ────────────────────────────────────────────
|
||||
@POST("api/ops/debug-share")
|
||||
suspend fun runDebugShare(
|
||||
@Body body: Map<String, Any>,
|
||||
): Response<DebugShareResponse>
|
||||
|
||||
// ── Admin: Checkpoints ────────────────────────────────────────────
|
||||
@GET("api/ops/checkpoints")
|
||||
suspend fun getCheckpoints(): Response<CheckpointsResponse>
|
||||
|
||||
@POST("api/ops/checkpoints/prune")
|
||||
suspend fun pruneCheckpoints(): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Shell hooks ────────────────────────────────────────────
|
||||
@GET("api/ops/hooks")
|
||||
suspend fun getHooks(): Response<HookResponse>
|
||||
|
||||
@POST("api/ops/hooks")
|
||||
suspend fun createHook(
|
||||
@Body body: Map<String, Any>,
|
||||
): Response<Map<String, Any>>
|
||||
|
||||
@DELETE("api/ops/hooks")
|
||||
suspend fun deleteHook(
|
||||
@Body body: Map<String, String>,
|
||||
): Response<Map<String, Any>>
|
||||
|
||||
// ── Admin: Action status (log viewer) ────────────────────────────
|
||||
@GET("api/actions/{name}/status")
|
||||
suspend fun getActionStatus(
|
||||
@Path("name") name: String,
|
||||
@Query("lines") lines: Int = 200,
|
||||
): Response<ActionStatusResponse>
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<CredentialPoolProvider> = 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<SystemUiState> = _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<Unit>,
|
||||
) {
|
||||
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<String, Any> {
|
||||
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<ActionResponse>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user