diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 401abd8f..e6a9c0ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -50,3 +50,5 @@ import kotlinx.serialization.Serializable @Serializable data object KanbanScreen : NavKey @Serializable data object ProcessesScreen : NavKey + +@Serializable data object AnalyticsScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index 2d92c6f7..18f60648 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/AnalyticsResponses.kt b/app/src/main/java/com/m57/hermescontrol/data/model/AnalyticsResponses.kt new file mode 100644 index 00000000..4c148495 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/AnalyticsResponses.kt @@ -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 = emptyList(), + val by_model: List = emptyList(), + val totals: AnalyticsTotals = AnalyticsTotals(), + val period_days: Int = 0, + val skills: AnalyticsSkills = AnalyticsSkills(), + val tools: List = 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 = 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 = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 09868faf..52b6e909 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + @GET("api/analytics/usage") + suspend fun getAnalytics( + @Query("days") days: Int, + @Query("profile") profile: String? = null, + ): Response + + @GET("api/analytics/models") + suspend fun getModelsAnalytics( + @Query("days") days: Int, + @Query("profile") profile: String? = null, + ): Response + @GET("api/skills") suspend fun getSkills(): Response> diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt index 121ce4f1..c4ec920d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt @@ -12,7 +12,7 @@ data class CommandCatalog( val sub: Map> = emptyMap(), val canon: Map = emptyMap(), val categories: List = emptyList(), - val skillCount: Int = 0, + val skillCount: Double = 0.0, val warning: String = "", ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt new file mode 100644 index 00000000..188b5167 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt @@ -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) { + 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() + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsViewModel.kt new file mode 100644 index 00000000..683b8f4e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsViewModel.kt @@ -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 = _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() + + /** 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 { api.getAnalytics(days, profile) } } + val modelsDeferred = + async { safeApiCall { 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 { api.getAnalytics(otherDays, profile) } } + val modelsDeferred = + async { safeApiCall { 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() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1397d54e..85d0d902 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,7 @@ Achievements History Processes + Analytics Hermes @@ -850,6 +851,26 @@ Running Kill process? Stop \u201c%1$s\u201d? This cannot be undone. + + + No analytics yet + Usage stats will appear here once the agent has been active. + %1$d days + Est. cost + Actual cost + Sessions + API calls + Input + Output + Cache read + Reasoning + Daily estimated cost + Per-model breakdown + Top skills + No cost recorded in this period. + %1$d uses \u2022 %2$d%% of total + Top tools + %1$d calls \u2022 %2$d%% of total Kill Kill process \ No newline at end of file diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt index 4d472380..9d7c044c 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt @@ -312,4 +312,127 @@ class HermesApiServiceTest { assertNull(stats?.cpu_percent) assertNull(stats?.memory?.percent) } + + @Test + fun testGetAnalytics_requestsCorrectPathAndQuery() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """{"daily":[],"by_model":[""" + + """{"model":"gpt-4o","input_tokens":10,"output_tokens":20,""" + + """"estimated_cost":0.01,"sessions":1,"api_calls":2}],""" + + """"totals":{"total_input":10,"total_output":20,""" + + """"total_estimated_cost":0.01,"total_sessions":1,"total_api_calls":2},""" + + """"skills":{"summary":{"total_skill_loads":0,"distinct_skills_used":0},""" + + """"top_skills":[]}}""", + ), + ) + + val response = apiService.getAnalytics(30, null) + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(1, body?.by_model?.size) + assertEquals("gpt-4o", body?.by_model?.first()?.model) + assertEquals(0.01, body?.totals?.total_estimated_cost) + + val recorded = mockWebServer.takeRequest() + assertEquals("/api/analytics/usage?days=30", recorded.path) + assertEquals("GET", recorded.method) + } + + @Test + fun testGetModelsAnalytics_forwardsProfileParam() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """{"models":[""" + + """{"model":"claude-opus","provider":"anthropic","input_tokens":5,"output_tokens":7,""" + + """"cache_read_tokens":1,"reasoning_tokens":2,"estimated_cost":0.05,"actual_cost":0.04,""" + + """"sessions":3,"api_calls":9,"tool_calls":1,""" + + """"last_used_at":1700000000.0,"avg_tokens_per_session":4.0}],""" + + """"totals":{"distinct_models":1,"total_input":5,"total_output":7,"total_cache_read":1,""" + + """"total_reasoning":2,"total_estimated_cost":0.05,"total_actual_cost":0.04,""" + + """"total_sessions":3,"total_api_calls":9},"period_days":30}""", + ), + ) + + val response = apiService.getModelsAnalytics(30, "work") + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(1, body?.models?.size) + assertEquals("claude-opus", body?.models?.first()?.model) + assertEquals("anthropic", body?.models?.first()?.provider) + assertEquals(30, body?.period_days) + + val recorded = mockWebServer.takeRequest() + assertEquals("/api/analytics/models?days=30&profile=work", recorded.path) + } + + @Test + fun testGetAnalytics_missingTotalsUsesDefaults_notNullable() = + runTest { + // Backend omits `totals` and most daily fields in some responses; + // the model uses non-null defaults, so parsing must NOT fail or + // produce nulls (counter-check to a review claim that fields are + // nullable). Verifies kotlinx.serialization fills defaults. + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """{"daily":[{"day":"2026-07-01"}],"by_model":[]}""", + ), + ) + + val response = apiService.getAnalytics(7, null) + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + // totals defaults (non-null), not null + assertNotNull(body?.totals) + assertEquals(0, body?.totals?.total_input) + assertEquals(0.0, body?.totals?.total_estimated_cost) + // daily entry sparse fields default (non-null) + assertEquals(1, body?.daily?.size) + assertEquals("2026-07-01", body?.daily?.first()?.day) + assertEquals(0L, body?.daily?.first()?.input_tokens) + } + + @Test + fun testGetAnalytics_parsesExactBackendShape() = + runTest { + // Exact shape produced by hermes_cli/web_server.py get_usage_analytics, + // including the extra top-level `period_days` and `tools` keys (the + // mobile model ignores `tools` and captures `period_days`). + val json = + """{"daily":[""" + + """{"day":"2026-07-01","input_tokens":100,"output_tokens":200,"api_calls":7}],""" + + """"by_model":[{"model":"gpt-4o","input_tokens":100,"api_calls":7}],""" + + """"totals":{"total_input":100,"total_estimated_cost":0.03,"total_api_calls":7},""" + + """"period_days":7,"skills":{"summary":{"distinct_skills_used":2},""" + + """"top_skills":[{"skill":"x","total_count":1,"percentage":50.0,"last_used_at":1700000000.0}]},""" + + """"tools":[{"tool":"terminal","count":21820,"percentage":44.26}]}""" + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setBody(json), + ) + + val response = apiService.getAnalytics(7, null) + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(7, body?.period_days) + assertEquals(1, body?.daily?.size) + assertEquals(100L, body?.daily?.first()?.input_tokens) + assertEquals(0.03, body?.totals?.total_estimated_cost) + assertEquals(2, body?.skills?.summary?.distinct_skills_used) + assertEquals("x", body?.skills?.top_skills?.first()?.skill) + assertEquals(1, body?.tools?.size) + assertEquals("terminal", body?.tools?.first()?.tool) + assertEquals(21820, body?.tools?.first()?.count) + } }