feat(#537): analytics screen — usage + per-model stats (#558)

* feat(#537): analytics screen — usage + per-model stats from /api/analytics/*

* fix(#537): address PR review — cancel-in-flight loads, retain cache on refresh, drop redundant async

* fix(#537): surface real analytics failure, mirror backend shape (period_days/tools), round-trip test

* fix(#537): tolerate float last_used_at in analytics (backend emits epoch as number)

* fix(#537): type tools[] as AnalyticsToolUsage (tool/count/percentage), render Top tools section

* fix(analytics): fix crashes and refactor tab layout

* perf(analytics): cache responses and prefetch day windows

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-12 00:04:56 +03:00
1 parent afa82ceec2
commit 9156064d2b
9 files changed
+1031 -1

No files matched your search

@@ -0,0 +1,125 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/**
* Usage analytics over a trailing window, returned by `GET /api/analytics/usage`.
* Mirrors `web/src/lib/api.ts` `AnalyticsResponse`.
*/
@Serializable
data class AnalyticsResponse(
val daily: List<AnalyticsDailyEntry> = emptyList(),
val by_model: List<AnalyticsModelEntry> = emptyList(),
val totals: AnalyticsTotals = AnalyticsTotals(),
val period_days: Int = 0,
val skills: AnalyticsSkills = AnalyticsSkills(),
val tools: List<AnalyticsToolUsage> = emptyList(),
)
@Serializable
data class AnalyticsToolUsage(
val tool: String = "",
val count: Int = 0,
val percentage: Double = 0.0,
)
@Serializable
data class AnalyticsDailyEntry(
val day: String = "",
val input_tokens: Long = 0,
val output_tokens: Long = 0,
val cache_read_tokens: Long = 0,
val reasoning_tokens: Long = 0,
val estimated_cost: Double = 0.0,
val actual_cost: Double = 0.0,
val sessions: Int = 0,
val api_calls: Int = 0,
)
@Serializable
data class AnalyticsModelEntry(
val model: String = "",
val input_tokens: Long = 0,
val output_tokens: Long = 0,
val estimated_cost: Double = 0.0,
val sessions: Int = 0,
val api_calls: Int = 0,
)
@Serializable
data class AnalyticsTotals(
val total_input: Long = 0,
val total_output: Long = 0,
val total_cache_read: Long = 0,
val total_reasoning: Long = 0,
val total_estimated_cost: Double = 0.0,
val total_actual_cost: Double = 0.0,
val total_sessions: Int = 0,
val total_api_calls: Int = 0,
)
@Serializable
data class AnalyticsSkills(
val summary: AnalyticsSkillsSummary = AnalyticsSkillsSummary(),
val top_skills: List<AnalyticsSkillEntry> = emptyList(),
)
@Serializable
data class AnalyticsSkillEntry(
val skill: String = "",
val view_count: Int = 0,
val manage_count: Int = 0,
val total_count: Int = 0,
val percentage: Double = 0.0,
val last_used_at: Double? = null,
)
@Serializable
data class AnalyticsSkillsSummary(
val total_skill_loads: Int = 0,
val total_skill_edits: Int = 0,
val total_skill_actions: Int = 0,
val distinct_skills_used: Int = 0,
)
/**
* Per-model analytics over a trailing window, returned by
* `GET /api/analytics/models`. Mirrors `web/src/lib/api.ts`
* `ModelsAnalyticsResponse`.
*/
@Serializable
data class ModelsAnalyticsResponse(
val models: List<ModelsAnalyticsModelEntry> = emptyList(),
val totals: ModelsAnalyticsTotals = ModelsAnalyticsTotals(),
val period_days: Int = 0,
)
@Serializable
data class ModelsAnalyticsModelEntry(
val model: String = "",
val provider: String = "",
val input_tokens: Long = 0,
val output_tokens: Long = 0,
val cache_read_tokens: Long = 0,
val reasoning_tokens: Long = 0,
val estimated_cost: Double = 0.0,
val actual_cost: Double = 0.0,
val sessions: Int = 0,
val api_calls: Int = 0,
val tool_calls: Int = 0,
val last_used_at: Double? = null,
val avg_tokens_per_session: Double = 0.0,
)
@Serializable
data class ModelsAnalyticsTotals(
val distinct_models: Int = 0,
val total_input: Long = 0,
val total_output: Long = 0,
val total_cache_read: Long = 0,
val total_reasoning: Long = 0,
val total_estimated_cost: Double = 0.0,
val total_actual_cost: Double = 0.0,
val total_sessions: Int = 0,
val total_api_calls: Int = 0,
)
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.model.ActionStatusResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.BulkDeleteRequest
import com.m57.hermescontrol.data.model.CheckpointsResponse
@@ -45,6 +46,7 @@ import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
@@ -169,6 +171,18 @@ interface HermesApiService {
@GET("api/system/stats")
suspend fun getSystemStats(): Response<SystemStatsResponse>
@GET("api/analytics/usage")
suspend fun getAnalytics(
@Query("days") days: Int,
@Query("profile") profile: String? = null,
): Response<AnalyticsResponse>
@GET("api/analytics/models")
suspend fun getModelsAnalytics(
@Query("days") days: Int,
@Query("profile") profile: String? = null,
): Response<ModelsAnalyticsResponse>
@GET("api/skills")
suspend fun getSkills(): Response<List<Skill>>
@@ -12,7 +12,7 @@ data class CommandCatalog(
val sub: Map<String, List<String>> = emptyMap(),
val canon: Map<String, String> = emptyMap(),
val categories: List<CommandCategory> = emptyList(),
val skillCount: Int = 0,
val skillCount: Double = 0.0,
val warning: String = "",
)