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