feat(#434): System tab 1:1 parity with dashboard (#461)

* 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:
authored and GitHub committed 2026-07-03 21:27:20 +03:00
1 parent e2349eca22
commit 846a9380e2
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>
}