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,
)