diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py index 544cdc5..822b8b2 100644 --- a/backend/app/api/analytics.py +++ b/backend/app/api/analytics.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Query from tortoise import connections from app.models import LogLevel -from app.schemas import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell +from app.schemas import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell, TopUsersVolumeResponse, TopUsersVolumeBucket from app.api.deps import get_team_member, CurrentUser router = APIRouter() @@ -137,6 +137,62 @@ async def analytics_top( return TopResponse(items=[TopItem(value=r["value"], count=r["count"]) for r in rows]) +@router.get("/{team_id}/analytics/top-users-volume", response_model=TopUsersVolumeResponse) +async def analytics_top_users_volume( + team_id: UUID, + user: CurrentUser, + bucket: Literal["hour", "day", "week"] = "hour", + limit: int = Query(10, ge=1, le=50), + from_time: datetime | None = Query(None, alias="from"), + to_time: datetime | None = Query(None, alias="to"), +): + team, _ = await get_team_member(team_id, user) + start, end = _default_range(from_time, to_time) + conn = connections.get("default") + + trunc = BUCKET_SQL[bucket] + + # Step 1: find top N user_ids by total count + top_rows = await conn.execute_query_dict( + """ + SELECT user_id, count(*) AS count + FROM logs + WHERE team_id = $1 AND timestamp >= $2 AND timestamp <= $3 + AND user_id IS NOT NULL + GROUP BY user_id + ORDER BY count DESC + LIMIT $4 + """, + [str(team.id), start, end, limit], + ) + + user_ids = [r["user_id"] for r in top_rows] + + if not user_ids: + return TopUsersVolumeResponse(users=[], buckets=[]) + + # Step 2: bucket counts for only those user_ids + placeholders = ", ".join(f"${i}" for i in range(4, 4 + len(user_ids))) + rows = await conn.execute_query_dict( + f""" + SELECT {trunc} AS bucket, user_id, count(*) AS count + FROM logs + WHERE team_id = $1 AND timestamp >= $2 AND timestamp <= $3 + AND user_id IN ({placeholders}) + GROUP BY bucket, user_id + ORDER BY bucket, user_id + """, + [str(team.id), start, end, *user_ids], + ) + + buckets = [ + TopUsersVolumeBucket(bucket=str(r["bucket"]), user_id=r["user_id"], count=r["count"]) + for r in rows + ] + + return TopUsersVolumeResponse(users=user_ids, buckets=buckets) + + @router.get("/{team_id}/analytics/heatmap", response_model=HeatmapResponse) async def analytics_heatmap( team_id: UUID, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 159fd8a..958f2ea 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,7 +2,7 @@ from app.schemas.user import UserCreate, UserUpdate, UserResponse from app.schemas.team import TeamCreate, TeamUpdate, TeamResponse, TeamWithKey, MembershipCreate, MembershipResponse from app.schemas.log import LogCreate, LogBatchCreate, LogResponse, LogSearchParams, UserIdBackfillRequest, UserIdBackfillResponse -from app.schemas.analytics import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell +from app.schemas.analytics import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell, TopUsersVolumeResponse, TopUsersVolumeBucket __all__ = [ "Token", "TokenPayload", "LoginRequest", "RefreshRequest", @@ -11,4 +11,5 @@ "LogCreate", "LogBatchCreate", "LogResponse", "LogSearchParams", "UserIdBackfillRequest", "UserIdBackfillResponse", "VolumeResponse", "VolumeBucket", "TopResponse", "TopItem", "HeatmapResponse", "HeatmapCell", + "TopUsersVolumeResponse", "TopUsersVolumeBucket", ] diff --git a/backend/app/schemas/analytics.py b/backend/app/schemas/analytics.py index 74a860b..6da6d8f 100644 --- a/backend/app/schemas/analytics.py +++ b/backend/app/schemas/analytics.py @@ -32,3 +32,14 @@ class HeatmapResponse(BaseModel): sources: list[str] levels: list[str] data: list[HeatmapCell] + + +class TopUsersVolumeBucket(BaseModel): + bucket: str + user_id: str + count: int + + +class TopUsersVolumeResponse(BaseModel): + users: list[str] + buckets: list[TopUsersVolumeBucket] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5126551..0bff80d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -139,3 +139,14 @@ export interface HeatmapResponse { levels: string[] data: HeatmapCell[] } + +export interface TopUsersVolumeBucket { + bucket: string + user_id: string + count: number +} + +export interface TopUsersVolumeResponse { + users: string[] + buckets: TopUsersVolumeBucket[] +} diff --git a/frontend/src/composables/useAnalytics.ts b/frontend/src/composables/useAnalytics.ts index 6542c38..09b1763 100644 --- a/frontend/src/composables/useAnalytics.ts +++ b/frontend/src/composables/useAnalytics.ts @@ -3,6 +3,7 @@ import api, { type VolumeResponse, type TopResponse, type HeatmapResponse, + type TopUsersVolumeResponse, } from '@/api/client' export interface TimeRange { @@ -15,6 +16,7 @@ export function useAnalytics(teamId: string) { const topSources = ref(null) as Ref const topErrors = ref(null) as Ref const topUsers = ref(null) as Ref + const topUsersVolume = ref(null) as Ref const heatmap = ref(null) as Ref const loading = ref(false) @@ -26,7 +28,7 @@ export function useAnalytics(teamId: string) { loading.value = true try { const params = rangeParams(range) - const [volRes, srcRes, errRes, usrRes, hmRes] = await Promise.all([ + const [volRes, srcRes, errRes, usrRes, tuvRes, hmRes] = await Promise.all([ api.get(`/teams/${teamId}/analytics/volume`, { params: { ...params, bucket, split_by: 'level' }, }), @@ -39,6 +41,9 @@ export function useAnalytics(teamId: string) { api.get(`/teams/${teamId}/analytics/top`, { params: { ...params, field: 'user_id' }, }), + api.get(`/teams/${teamId}/analytics/top-users-volume`, { + params: { ...params, bucket }, + }), api.get(`/teams/${teamId}/analytics/heatmap`, { params, }), @@ -47,6 +52,7 @@ export function useAnalytics(teamId: string) { topSources.value = srcRes.data topErrors.value = errRes.data topUsers.value = usrRes.data + topUsersVolume.value = tuvRes.data heatmap.value = hmRes.data } catch (e) { console.error('Failed to fetch analytics:', e) @@ -60,6 +66,7 @@ export function useAnalytics(teamId: string) { topSources, topErrors, topUsers, + topUsersVolume, heatmap, loading, fetchAll, diff --git a/frontend/src/composables/useChartOptions.ts b/frontend/src/composables/useChartOptions.ts index 8c27e74..e90e220 100644 --- a/frontend/src/composables/useChartOptions.ts +++ b/frontend/src/composables/useChartOptions.ts @@ -3,6 +3,7 @@ import type { VolumeResponse, TopResponse, HeatmapResponse, + TopUsersVolumeResponse, } from '@/api/client' const LEVEL_COLORS: Record = { @@ -23,11 +24,17 @@ function formatBucket(iso: string): string { return `${month}/${day} ${hour}:00` } +const USER_COLORS = [ + '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', + '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#48b8d0', +] + export function useChartOptions( volume: Ref, topSources: Ref, topErrors: Ref, topUsers: Ref, + topUsersVolume: Ref, heatmap: Ref, ) { @@ -182,7 +189,38 @@ export function useChartOptions( } }) - // Chart 7: Source x Level heatmap + // Chart 7: Top users volume over time (multi-series line) + const topUsersVolumeOption = computed(() => { + const data = topUsersVolume.value + if (!data || data.buckets.length === 0) return null + + const bucketSet = [...new Set(data.buckets.map(b => b.bucket))].sort() + const xLabels = bucketSet.map(formatBucket) + + const series = data.users.map((userId, idx) => { + const counts = bucketSet.map(bucket => { + const entry = data.buckets.find(b => b.bucket === bucket && b.user_id === userId) + return entry?.count ?? 0 + }) + return { + name: userId, + type: 'line' as const, + data: counts, + itemStyle: { color: USER_COLORS[idx % USER_COLORS.length] }, + } + }) + + return { + tooltip: { trigger: 'axis' }, + legend: { data: data.users, top: 0, type: 'scroll' }, + grid: { left: 50, right: 20, bottom: 40, top: 40 }, + xAxis: { type: 'category', data: xLabels }, + yAxis: { type: 'value' }, + series, + } + }) + + // Chart 8: Source x Level heatmap const heatmapOption = computed(() => { const data = heatmap.value if (!data || data.data.length === 0) return null @@ -236,6 +274,7 @@ export function useChartOptions( topSourcesOption, topErrorsData, topUsersOption, + topUsersVolumeOption, heatmapOption, } } diff --git a/frontend/src/views/AnalyticsView.vue b/frontend/src/views/AnalyticsView.vue index b26e707..9fb1833 100644 --- a/frontend/src/views/AnalyticsView.vue +++ b/frontend/src/views/AnalyticsView.vue @@ -7,6 +7,21 @@

{{ teamName }} Analytics

+ + + + + + {{ c.label }} + + + - + - Log Volume Over Time + + Log Volume Over Time + + mdi-close +
No data for this time range
- + - Level Breakdown + + Level Breakdown + + mdi-close +
No data
@@ -47,18 +70,26 @@ - + - Error Rate Over Time + + Error Rate Over Time + + mdi-close +
No data
- + - Top Sources + + Top Sources + + mdi-close +
No data
@@ -69,9 +100,13 @@ - + - Top Error Messages + + Top Error Messages + + mdi-close + @@ -91,9 +126,13 @@ - + - Logs per User + + Logs per User + + mdi-close +
No user data
@@ -102,11 +141,32 @@
- - + + + + + + Top Users Volume Over Time + + mdi-close + + + +
No user data
+
+
+
+
+ + + - Source x Level Heatmap + + Source x Level Heatmap + + mdi-close +
No data
@@ -118,7 +178,7 @@