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