* 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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user