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

@@ -50,3 +50,5 @@ import kotlinx.serialization.Serializable
@Serializable data object KanbanScreen : NavKey
@Serializable data object ProcessesScreen : NavKey
@Serializable data object AnalyticsScreen : NavKey
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.automirrored.filled.ListAlt
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Code
@@ -24,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
@@ -173,6 +175,12 @@ object ScreenRegistry {
Icons.Filled.Memory,
DrawerSection.INSPECT,
) { sessionId, openDrawer -> ProcessesScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
AnalyticsScreen,
R.string.screen_analytics,
Icons.Filled.BarChart,
DrawerSection.INSPECT,
) { sessionId, openDrawer -> AnalyticsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
KanbanScreen,
R.string.screen_kanban,
@@ -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 = "",
)
@@ -0,0 +1,576 @@
package com.m57.hermescontrol.ui.analytics
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.AnalyticsDailyEntry
import com.m57.hermescontrol.data.model.ModelsAnalyticsModelEntry
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
private val DAY_OPTIONS = listOf(7, 30, 90)
@Composable
fun AnalyticsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: AnalyticsViewModel = viewModel { AnalyticsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
if (state.usage == null && state.models == null) viewModel.load()
}
HermesScaffold(
title = { Text(stringResource(R.string.screen_analytics)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
modifier = modifier,
) {
when {
state.isLoading && state.usage == null -> LoadingState()
state.errorMessage != null && state.usage == null ->
ErrorState(
message = state.errorMessage ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.load() },
)
state.usage == null ->
EmptyState(
title = stringResource(R.string.analytics_empty_title),
subtitle = stringResource(R.string.analytics_empty_desc),
icon = Icons.Filled.BarChart,
)
else ->
AnalyticsContent(
state = state,
onDaysSelected = viewModel::setDays,
)
}
}
}
@Composable
private fun AnalyticsContent(
state: AnalyticsUiState,
onDaysSelected: (Int) -> Unit,
) {
val usage = state.usage ?: return
val models = state.models
var selectedSectionTab by remember { mutableStateOf(0) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
item {
DayRangeSelector(
selected = state.days,
onSelect = onDaysSelected,
)
}
item { TotalsCard(usage.totals) }
if (usage.daily.isNotEmpty()) {
item { SectionTitle(stringResource(R.string.analytics_daily_cost)) }
item { DailyCostChart(entries = usage.daily) }
}
item {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Most Used Components",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Overview of your most active models, skills, and tools.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item {
SecondaryTabRow(selectedTabIndex = selectedSectionTab) {
Tab(
selected = selectedSectionTab == 0,
onClick = { selectedSectionTab = 0 },
text = { Text("Models") },
)
Tab(
selected = selectedSectionTab == 1,
onClick = { selectedSectionTab = 1 },
text = { Text("Skills") },
)
Tab(
selected = selectedSectionTab == 2,
onClick = { selectedSectionTab = 2 },
text = { Text("Tools") },
)
}
}
if (selectedSectionTab == 0) {
if (models != null && models.models.isNotEmpty()) {
itemsIndexed(models.models, key = { index, it -> "model_${it.model}_$index" }) { _, model ->
ModelRow(model = model)
}
} else if (usage.by_model.isNotEmpty()) {
itemsIndexed(usage.by_model, key = { index, it -> "usage_${it.model}_$index" }) { _, entry ->
ModelEntryRow(entry = entry)
}
} else {
item {
Text(
text = "No model data available",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (selectedSectionTab == 1) {
if (usage.skills.top_skills.isNotEmpty()) {
itemsIndexed(usage.skills.top_skills, key = { index, it -> "skill_${it.skill}_$index" }) { _, skill ->
SkillRow(skill = skill)
}
} else {
item {
Text(
text = "No skill data available",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (selectedSectionTab == 2) {
if (usage.tools.isNotEmpty()) {
itemsIndexed(usage.tools, key = { index, it -> "tool_${it.tool}_$index" }) { _, tool ->
ToolRow(tool = tool)
}
} else {
item {
Text(
text = "No tool data available",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DayRangeSelector(
selected: Int,
onSelect: (Int) -> Unit,
) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
DAY_OPTIONS.forEach { days ->
FilterChip(
selected = days == selected,
onClick = { onSelect(days) },
label = { Text(stringResource(R.string.analytics_days, days)) },
)
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
@Composable
private fun TotalsCard(totals: com.m57.hermescontrol.data.model.AnalyticsTotals) {
val status = LocalHermesStatusColors.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = formatCost(totals.total_estimated_cost),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = status.info,
)
Text(
text = stringResource(R.string.analytics_estimated_cost),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Metric(stringResource(R.string.analytics_sessions), totals.total_sessions.toString())
Metric(stringResource(R.string.analytics_api_calls), totals.total_api_calls.toString())
Metric(stringResource(R.string.analytics_input_tokens), formatTokens(totals.total_input))
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Metric(stringResource(R.string.analytics_output_tokens), formatTokens(totals.total_output))
Metric(stringResource(R.string.analytics_cache_read), formatTokens(totals.total_cache_read))
Metric(stringResource(R.string.analytics_reasoning), formatTokens(totals.total_reasoning))
}
}
}
}
@Composable
private fun Metric(
label: String,
value: String,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* Compact bar chart of daily estimated cost. Pure Compose (no chart dependency)
* — each day is a vertical bar scaled to the max daily cost.
*/
@Composable
private fun DailyCostChart(entries: List<AnalyticsDailyEntry>) {
val primary = MaterialTheme.colorScheme.primary
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
val maxCost = entries.maxOfOrNull { it.estimated_cost } ?: 0.0
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
if (maxCost <= 0.0) {
Text(
text = stringResource(R.string.analytics_no_cost_data),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val barHeight = 96.dp
Row(
modifier =
Modifier
.fillMaxWidth()
.height(barHeight),
horizontalArrangement = Arrangement.spacedBy(3.dp),
verticalAlignment = Alignment.Bottom,
) {
entries.forEach { entry ->
val fraction = if (maxCost > 0.0) (entry.estimated_cost / maxCost).toFloat() else 0f
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(barHeight * fraction.coerceAtLeast(0.02f))
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
.background(primary),
)
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = entries.firstOrNull()?.day ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = entries.lastOrNull()?.day ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun ModelRow(model: ModelsAnalyticsModelEntry) {
val status = LocalHermesStatusColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = model.model,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = formatCost(model.estimated_cost),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = status.info,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
buildString {
append(model.provider)
append(" • ")
append(formatTokens(model.input_tokens + model.output_tokens))
append(" tokens • ")
append(stringResource(R.string.analytics_sessions).lowercase())
append(" ")
append(model.sessions)
append(" • ")
append(stringResource(R.string.analytics_api_calls).lowercase())
append(" ")
append(model.api_calls)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ModelEntryRow(entry: com.m57.hermescontrol.data.model.AnalyticsModelEntry) {
val status = LocalHermesStatusColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.model,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
buildString {
append(formatTokens(entry.input_tokens + entry.output_tokens))
append(" tokens • ")
append(entry.sessions)
append(" ")
append(stringResource(R.string.analytics_sessions).lowercase())
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = formatCost(entry.estimated_cost),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = status.info,
)
}
}
}
@Composable
private fun SkillRow(skill: com.m57.hermescontrol.data.model.AnalyticsSkillEntry) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = skill.skill,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
stringResource(
R.string.analytics_skill_uses,
skill.total_count,
skill.percentage.toInt(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ToolRow(tool: com.m57.hermescontrol.data.model.AnalyticsToolUsage) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = tool.tool,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
stringResource(
R.string.analytics_tool_uses,
tool.count,
tool.percentage.toInt(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun formatCost(value: Double): String =
if (value >= 100.0) {
"$${"%,.0f".format(value)}"
} else {
"$${"%.2f".format(value)}"
}
private fun formatTokens(value: Long): String =
when {
value >= 1_000_000_000_000L -> "%.1fT".format(value / 1_000_000_000_000.0)
value >= 1_000_000_000L -> "%.1fB".format(value / 1_000_000_000.0)
value >= 1_000_000L -> "%.1fM".format(value / 1_000_000.0)
value >= 1_000L -> "%.1fK".format(value / 1_000.0)
else -> value.toString()
}
@@ -0,0 +1,161 @@
package com.m57.hermescontrol.ui.analytics
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
private val DAY_OPTIONS = listOf(7, 30, 90)
/**
* Drives the Analytics screen (issue #537): historical usage + per-model stats
* from `GET /api/analytics/usage` and `GET /api/analytics/models`.
*
* `days` selects the trailing window (7 / 30 / 90). `profile` is optional and
* forwarded to the backend for multi-profile parity (#5) — `null` means the
* active/default profile, matching the desktop `getManagementProfile()` default.
*/
data class AnalyticsUiState(
val isLoading: Boolean = false,
val days: Int = 30,
val profile: String? = null,
val usage: AnalyticsResponse? = null,
val models: ModelsAnalyticsResponse? = null,
val errorMessage: String? = null,
)
class AnalyticsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(AnalyticsUiState())
val uiState: StateFlow<AnalyticsUiState> = _uiState.asStateFlow()
private val api get() = ApiClient.hermesApi
private var loadJob: Job? = null
/** In-memory cache keyed by days for instant tab switching. */
private data class CacheEntry(
val usage: AnalyticsResponse,
val models: ModelsAnalyticsResponse?,
)
private val cache = mutableMapOf<Int, CacheEntry>()
/** Reload both analytics endpoints for the current [days]/[profile]. */
fun load() {
loadJob?.cancel()
val days = _uiState.value.days
val profile = _uiState.value.profile
// Serve from cache instantly if available.
cache[days]?.let { cached ->
_uiState.update {
it.copy(
isLoading = false,
usage = cached.usage,
models = cached.models,
errorMessage = null,
)
}
// Refresh in background silently.
fetchAndCache(days, profile, showLoading = false)
return
}
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
fetchAndCache(days, profile, showLoading = true)
}
private fun fetchAndCache(
days: Int,
profile: String?,
showLoading: Boolean,
) {
loadJob =
viewModelScope.launch {
val usageDeferred =
async { safeApiCall<AnalyticsResponse> { api.getAnalytics(days, profile) } }
val modelsDeferred =
async { safeApiCall<ModelsAnalyticsResponse> { api.getModelsAnalytics(days, profile) } }
val usageResult = usageDeferred.await()
val modelsResult = modelsDeferred.await()
// On failure keep previously-loaded data visible instead of wiping it.
val usage = (usageResult as? NetworkResult.Success)?.data ?: _uiState.value.usage
val models = (modelsResult as? NetworkResult.Success)?.data ?: _uiState.value.models
// Cache successful results.
if (usage != null) {
cache[days] = CacheEntry(usage, models)
}
// Surface the REAL failure (HTTP code / connection / parse error)
// instead of a generic string so the root cause is never hidden.
val failure =
when {
usageResult is NetworkResult.Failure -> usageResult.error
modelsResult is NetworkResult.Failure -> modelsResult.error
else -> null
}
val error =
when {
usage == null && models == null ->
failure?.message ?: "Failed to load analytics"
usage == null ->
failure?.message ?: "Failed to load usage analytics"
models == null ->
failure?.message ?: "Failed to load model analytics"
else -> null
}
// Only update UI if this is still the active day window.
if (_uiState.value.days == days) {
_uiState.update {
it.copy(
isLoading = false,
usage = usage,
models = models,
errorMessage = error,
)
}
}
// Prefetch other day windows in the background.
if (showLoading) {
prefetchOtherWindows(days, profile)
}
}
}
/** Prefetch the other two day windows silently so tab switching is instant. */
private fun prefetchOtherWindows(
currentDays: Int,
profile: String?,
) {
DAY_OPTIONS.filter { it != currentDays }.forEach { otherDays ->
if (cache.containsKey(otherDays)) return@forEach
viewModelScope.launch {
val usageDeferred =
async { safeApiCall<AnalyticsResponse> { api.getAnalytics(otherDays, profile) } }
val modelsDeferred =
async { safeApiCall<ModelsAnalyticsResponse> { api.getModelsAnalytics(otherDays, profile) } }
val usageResult = (usageDeferred.await() as? NetworkResult.Success)?.data
val modelsResult = (modelsDeferred.await() as? NetworkResult.Success)?.data
if (usageResult != null) {
cache[otherDays] = CacheEntry(usageResult, modelsResult)
}
}
}
}
/** Change the trailing window and reload. */
fun setDays(days: Int) {
if (days == _uiState.value.days) return
_uiState.update { it.copy(days = days) }
load()
}
}