Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions frontend/cc-ui/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Usage Analytics v1 (PR-D). Data layer, mirroring interactions.ts's own
// shape exactly -- every call hits this service's own backend at a
// relative path (control_center.analytics.router, mounted at /analytics
// in main.py); no function here makes an authorization decision, that's
// entirely require_analytics_scope's job server-side (401/403 on a
// missing/insufficient token, re-checked on every single call below --
// the org_id/team_id this file sends are a UX convenience, never trusted
// client-side).
import { authHeaders, reportUnauthorized } from './auth'

async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const r = await fetch(path, {
...init,
headers: { ...authHeaders(), ...(init.headers ?? {}) },
})
if (r.status === 401) {
reportUnauthorized()
}
return r
}

export interface AnalyticsFilters {
fromDate?: string
toDate?: string
orgId?: number
teamId?: number
}

function buildQuery(filters: AnalyticsFilters): URLSearchParams {
const qs = new URLSearchParams()
if (filters.fromDate) qs.set('from_date', filters.fromDate)
if (filters.toDate) qs.set('to_date', filters.toDate)
if (filters.orgId != null) qs.set('org_id', String(filters.orgId))
if (filters.teamId != null) qs.set('team_id', String(filters.teamId))
return qs
}

// Mirrors control_center.analytics.service.get_overview's response
// exactly. `total_queries`/`active_users`/`workflows_run` are `null`,
// not fabricated, when the backend couldn't answer for this scope (a
// team-roster lookup failure, or a platform_admin asking about
// workflows -- see that module's own docstring for the full list) --
// this page renders those the same "--" way DashboardPage.tsx already
// established for a null MetricCard value.
export interface AnalyticsOverview {
total_queries: number | null
active_users: number | null
workflows_run: number | null
error_rate: number
from_date: string
to_date: string
org_id: number | null
team_id: number | null
team_scope_available?: boolean
}

export interface DailyCount {
date: string
count: number | null
}

export interface AnalyticsUsers {
daily: DailyCount[]
dau: number | null
wau: number | null
mau: number | null
org_id: number | null
team_id: number | null
team_scope_available?: boolean
}

export interface AnalyticsQueries {
daily: DailyCount[]
total_queries: number | null
from_date: string
to_date: string
org_id: number | null
team_id: number | null
team_scope_available?: boolean
}

export interface ServiceBreakdownRow {
service: string
total_calls: number
errors: number
error_rate: number
avg_latency_ms: number | null
}

export interface AnalyticsServices {
services: ServiceBreakdownRow[]
org_id: number | null
team_id: number | null
note?: string
}

// PLATFORM-WIDE ONLY -- org_id/team_id are always null here regardless
// of the caller's own filter selection (audit:events, this response's
// real source, carries no organization_id at all -- see aggregator.py's
// own module docstring). AnalyticsDashboard.tsx must never attribute
// these numbers to whatever org/team filter happens to be selected.
export interface AnalyticsPerformance {
scope: 'platform'
org_id: null
team_id: null
p50_latency_ms: number | null
p95_latency_ms: number | null
p99_latency_ms: number | null
error_rate: number
throughput_per_day: number
latency_source: 'prometheus' | 'events'
from_date: string
to_date: string
}

export interface AnalyticsWorkflows {
workflows_run: number | null
daily: DailyCount[] | null
success_rate: number | null
org_id: number | null
team_id: number | null
from_date: string
to_date: string
note?: string
}

export interface AnalyticsUsage {
org_id: number | null
team_id: number | null
billing_available: boolean
usage: Record<string, unknown> | null
limits: Record<string, unknown> | null
note?: string
}

async function getJson<T>(path: string, filters: AnalyticsFilters): Promise<T> {
const qs = buildQuery(filters)
const r = await apiFetch(`${path}?${qs.toString()}`)
if (!r.ok) throw new Error(`${path} ${r.status}`)
return r.json()
}

export const fetchAnalyticsOverview = (filters: AnalyticsFilters = {}) => getJson<AnalyticsOverview>('/analytics/overview', filters)
export const fetchAnalyticsQueries = (filters: AnalyticsFilters = {}) => getJson<AnalyticsQueries>('/analytics/queries', filters)
export const fetchAnalyticsUsers = (filters: AnalyticsFilters = {}) => getJson<AnalyticsUsers>('/analytics/users', filters)
export const fetchAnalyticsServices = (filters: AnalyticsFilters = {}) => getJson<AnalyticsServices>('/analytics/services', filters)
export const fetchAnalyticsWorkflows = (filters: AnalyticsFilters = {}) => getJson<AnalyticsWorkflows>('/analytics/workflows', filters)
export const fetchAnalyticsPerformance = (filters: AnalyticsFilters = {}) => getJson<AnalyticsPerformance>('/analytics/performance', filters)
export const fetchAnalyticsUsage = (filters: AnalyticsFilters = {}) => getJson<AnalyticsUsage>('/analytics/usage', filters)

export type ExportType = 'overview' | 'queries' | 'users' | 'services' | 'workflows' | 'performance'

// Triggers a browser download of the CSV the backend streams back
// (StreamingResponse, media_type text/csv) -- fetched (not a bare <a
// href>) specifically so the Authorization header can be attached; a
// plain link can't carry it. Throws the same `${path} ${status}` shape
// every other function in this file does, including a 403 -- an
// unauthorized export attempt surfaces the same way an unauthorized
// dashboard load already does, not a silently-empty file.
export async function exportAnalyticsCsv(type: ExportType, filters: AnalyticsFilters = {}): Promise<void> {
const qs = buildQuery(filters)
qs.set('type', type)
const r = await apiFetch(`/analytics/export?${qs.toString()}`)
if (!r.ok) throw new Error(`/analytics/export ${r.status}`)
const blob = await r.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `analytics_${type}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
21 changes: 19 additions & 2 deletions frontend/cc-ui/src/apps/AdminApp.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { fetchSummary, fetchReportStatus, triggerGenerate } from '../api'
import {
getSessionUser, hasAdminAccess, hasOrganizationsAccess, hasPlatformAdminAccess,
canSeeAnalytics as canSeeAnalyticsAccess, getSessionUser, hasAdminAccess, hasOrganizationsAccess, hasPlatformAdminAccess,
UNAUTHORIZED_EVENT,
} from '../auth'
import { findNavItem } from '../navigation'
Expand Down Expand Up @@ -33,6 +33,7 @@ import BillingPage from '../pages/billing/BillingPage'
import AuditLogsPage from '../pages/audit/AuditLogsPage'
import SessionsPage from '../pages/security/SessionsPage'
import InteractionsPage from '../pages/InteractionsPage'
import AnalyticsDashboard from '../pages/AnalyticsDashboard'
import SecurityDashboardPage from '../pages/security/SecurityDashboardPage'
import OrganizationMFAPolicyPage from '../pages/security/OrganizationMFAPolicyPage'
import AuthGate from './AuthGate'
Expand Down Expand Up @@ -128,6 +129,12 @@ function AdminDashboard() {
// PR-B5-B: same reasoning as canSeeAuditLogs -- GET /platform/
// interactions is manage_all_orgs-gated, not org-scoped.
const canSeeInteractions = hasPlatformAdminAccess()
// Usage Analytics v1 (PR-D): narrower than canSeeOrganizations --
// mirrors require_analytics_scope's own platform_admin/org_admin/
// team_admin/deny matrix exactly, not the broader "any org member"
// gate 'billing'/'iam' use, since a regular org member is denied
// analytics access entirely by the backend.
const canSeeAnalytics = canSeeAnalyticsAccess()
// PR11.5.6: same reasoning as canSeeAuditLogs -- GET /platform/users,
// GET /platform/orgs, GET /platform/audit-events (everything the
// Security Dashboard reads) are all manage_all_orgs-gated.
Expand Down Expand Up @@ -325,7 +332,7 @@ function AdminDashboard() {
) : undefined}
>
{renderPage(active, {
canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeInteractions, canSeeSecurityOverview, refreshKey,
canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeInteractions, canSeeAnalytics, canSeeSecurityOverview, refreshKey,
selectedOrgId, setSelectedOrgId, selectedUserId, setSelectedUserId,
teamsOrgHint, rolesOrgHint, onViewTeams: handleViewTeams, onViewRoles: handleViewRoles,
selectedSsoOrgId, setSelectedSsoOrgId, navigateToSsoSettings,
Expand All @@ -344,6 +351,7 @@ interface RenderCtx {
canSeeUsers: boolean
canSeeAuditLogs: boolean
canSeeInteractions: boolean
canSeeAnalytics: boolean
canSeeSecurityOverview: boolean
refreshKey: number
selectedOrgId: number | null
Expand Down Expand Up @@ -532,6 +540,15 @@ function renderPage(active: PageKey, ctx: RenderCtx) {
? <BillingPage orgId={ctx.selectedBillingOrgId} onBack={() => ctx.setSelectedBillingOrgId(null)} />
: <OrganizationsPage onSelect={ctx.setSelectedBillingOrgId} />

// Usage Analytics v1 (PR-D): flat, no org-picker/deep-link -- unlike
// 'billing' immediately above, the page itself resolves/filters
// scope in-page (an in-page organization <select> for a
// platform_admin only -- see AnalyticsDashboard.tsx), same "flat
// platform-wide page" shape 'audit-logs'/'interactions' already use.
case 'analytics':
if (!ctx.canSeeAnalytics) return null
return <AnalyticsDashboard />

// PR-B6: no org picker -- every integration this page reads is
// genuinely platform-wide (a deployment env var), not org-scoped.
// Same canSeeOps gate 'cloud'/'settings' above already use.
Expand Down
17 changes: 17 additions & 0 deletions frontend/cc-ui/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,23 @@ export function hasOrganizationsAccess(): boolean {
return hasAdminAccess() || hasPlatformAdminAccess() || cachedUser?.orgId != null
}

// Usage Analytics v1 (PR-D). UX-only mirror of the backend's own
// require_analytics_scope (control_center.analytics.permissions) --
// platform_admin (manage_all_orgs) / org_admin (orgRoles contains
// "org_admin") / team_admin (teamRole === "admin") may see the nav
// entry and the page; a regular member sees neither. The backend
// dependency re-checks this independently (and org/team-scopes every
// response) on every single request -- this only decides whether
// AnalyticsDashboard.tsx is worth rendering at all, same caveat every
// other has*Access() function in this file already carries.
export function canSeeAnalytics(): boolean {
return (
hasPlatformAdminAccess() ||
(cachedUser?.orgRoles.includes('org_admin') ?? false) ||
cachedUser?.teamRole === 'admin'
)
}

// Fired whenever a gated request comes back 401 (missing/expired/invalid
// token) so App.tsx can drop back to the login screen without every
// call site needing to know about auth.
Expand Down
16 changes: 15 additions & 1 deletion frontend/cc-ui/src/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { hasAdminAccess, hasOrganizationsAccess, hasPlatformAdminAccess } from './auth'
import { canSeeAnalytics, hasAdminAccess, hasOrganizationsAccess, hasPlatformAdminAccess } from './auth'

/**
* Admin Console Phase 2: the single source of truth for the sectioned
Expand Down Expand Up @@ -26,6 +26,7 @@ export type PageKey =
| 'organizations' | 'users' | 'teams' | 'roles'
| 'infrastructure' | 'workflows' | 'tool-execution' | 'ai-models'
| 'security-overview' | 'mfa-policy' | 'iam' | 'audit-logs' | 'sessions' | 'interactions' | 'api-keys'
| 'analytics'
| 'billing'
| 'rag' | 'pubmed'
| 'integrations' | 'settings'
Expand Down Expand Up @@ -242,6 +243,19 @@ export const NAVIGATION: NavSection[] = [
// entirely backend-side, per-org, via omnibioai-billing's own
// app.core.iam.get_authorized_organization_id/get_authorized_invoice.
{ key: 'billing', label: 'Billing', functional: true, visible: hasOrganizationsAccess },
// Usage Analytics v1 (PR-D). A real page (AnalyticsDashboard.tsx),
// no org picker in front of it (unlike 'billing'/'iam'/'api-keys'
// above) -- the backend's own require_analytics_scope resolves
// org_id/team_id from the caller's token, and the page itself
// offers an in-page organization filter only for a platform_admin
// (the only role with more than one org to choose from). `visible`
// is canSeeAnalytics -- narrower than hasOrganizationsAccess:
// platform_admin, an org's own org_admin, or a team's own
// team_admin only, mirroring require_analytics_scope's own
// platform_admin/org_admin/team_admin/deny matrix exactly (a
// regular org member is denied both here and, independently and
// authoritatively, by the backend).
{ key: 'analytics', label: 'Usage Analytics', functional: true, visible: canSeeAnalytics },
// PR B (Admin Console Billing/Usage consolidation): 'licenses' and
// 'usage' removed from this section, not flipped to functional.
// Both were Coming Soon placeholders that, on audit, turned out to
Expand Down
Loading
Loading