* 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:
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 KanbanScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ProcessesScreen : 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.Chat
|
||||||
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
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.Bolt
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.Code
|
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.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
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.channels.ChannelsScreen as ChannelsScreenContent
|
||||||
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
||||||
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
||||||
@@ -173,6 +175,12 @@ object ScreenRegistry {
|
|||||||
Icons.Filled.Memory,
|
Icons.Filled.Memory,
|
||||||
DrawerSection.INSPECT,
|
DrawerSection.INSPECT,
|
||||||
) { sessionId, openDrawer -> ProcessesScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> ProcessesScreenContent(onOpenDrawer = openDrawer) },
|
||||||
|
ScreenDefinition(
|
||||||
|
AnalyticsScreen,
|
||||||
|
R.string.screen_analytics,
|
||||||
|
Icons.Filled.BarChart,
|
||||||
|
DrawerSection.INSPECT,
|
||||||
|
) { sessionId, openDrawer -> AnalyticsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
KanbanScreen,
|
KanbanScreen,
|
||||||
R.string.screen_kanban,
|
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.ActiveProfileResponse
|
||||||
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
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.AuxiliaryModelsResponse
|
||||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||||
import com.m57.hermescontrol.data.model.CheckpointsResponse
|
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.ModelAssignmentRequest
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
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.PairingApproveRequest
|
||||||
import com.m57.hermescontrol.data.model.PairingResponse
|
import com.m57.hermescontrol.data.model.PairingResponse
|
||||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||||
@@ -169,6 +171,18 @@ interface HermesApiService {
|
|||||||
@GET("api/system/stats")
|
@GET("api/system/stats")
|
||||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
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")
|
@GET("api/skills")
|
||||||
suspend fun getSkills(): Response<List<Skill>>
|
suspend fun getSkills(): Response<List<Skill>>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ data class CommandCatalog(
|
|||||||
val sub: Map<String, List<String>> = emptyMap(),
|
val sub: Map<String, List<String>> = emptyMap(),
|
||||||
val canon: Map<String, String> = emptyMap(),
|
val canon: Map<String, String> = emptyMap(),
|
||||||
val categories: List<CommandCategory> = emptyList(),
|
val categories: List<CommandCategory> = emptyList(),
|
||||||
val skillCount: Int = 0,
|
val skillCount: Double = 0.0,
|
||||||
val warning: String = "",
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
<string name="screen_achievements">Achievements</string>
|
<string name="screen_achievements">Achievements</string>
|
||||||
<string name="screen_history">History</string>
|
<string name="screen_history">History</string>
|
||||||
<string name="screen_processes">Processes</string>
|
<string name="screen_processes">Processes</string>
|
||||||
|
<string name="screen_analytics">Analytics</string>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">Hermes</string>
|
||||||
@@ -850,6 +851,26 @@
|
|||||||
<string name="processes_running_hint">Running</string>
|
<string name="processes_running_hint">Running</string>
|
||||||
<string name="processes_kill_title">Kill process?</string>
|
<string name="processes_kill_title">Kill process?</string>
|
||||||
<string name="processes_kill_desc">Stop \u201c%1$s\u201d? This cannot be undone.</string>
|
<string name="processes_kill_desc">Stop \u201c%1$s\u201d? This cannot be undone.</string>
|
||||||
|
|
||||||
|
<!-- Analytics Screen (issue #537) -->
|
||||||
|
<string name="analytics_empty_title">No analytics yet</string>
|
||||||
|
<string name="analytics_empty_desc">Usage stats will appear here once the agent has been active.</string>
|
||||||
|
<string name="analytics_days">%1$d days</string>
|
||||||
|
<string name="analytics_estimated_cost">Est. cost</string>
|
||||||
|
<string name="analytics_actual_cost">Actual cost</string>
|
||||||
|
<string name="analytics_sessions">Sessions</string>
|
||||||
|
<string name="analytics_api_calls">API calls</string>
|
||||||
|
<string name="analytics_input_tokens">Input</string>
|
||||||
|
<string name="analytics_output_tokens">Output</string>
|
||||||
|
<string name="analytics_cache_read">Cache read</string>
|
||||||
|
<string name="analytics_reasoning">Reasoning</string>
|
||||||
|
<string name="analytics_daily_cost">Daily estimated cost</string>
|
||||||
|
<string name="analytics_per_model">Per-model breakdown</string>
|
||||||
|
<string name="analytics_top_skills">Top skills</string>
|
||||||
|
<string name="analytics_no_cost_data">No cost recorded in this period.</string>
|
||||||
|
<string name="analytics_skill_uses">%1$d uses \u2022 %2$d%% of total</string>
|
||||||
|
<string name="analytics_top_tools">Top tools</string>
|
||||||
|
<string name="analytics_tool_uses">%1$d calls \u2022 %2$d%% of total</string>
|
||||||
<string name="processes_kill_confirm">Kill</string>
|
<string name="processes_kill_confirm">Kill</string>
|
||||||
<string name="processes_kill_cd">Kill process</string>
|
<string name="processes_kill_cd">Kill process</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -312,4 +312,127 @@ class HermesApiServiceTest {
|
|||||||
assertNull(stats?.cpu_percent)
|
assertNull(stats?.cpu_percent)
|
||||||
assertNull(stats?.memory?.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user