From 93fe1613d70a194c4fdcd7cd9918775a814b04c0 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 16:32:20 +0000 Subject: [PATCH] feat: Add Usage dashboard for organization admins and owners This PR adds a Usage page to the Settings menu that displays organization usage statistics including: - Total number of conversations - Number of merged PRs - Average cost per conversation - A chart showing conversation counts over the last 90 days The page is only visible to users with admin or owner roles in team organizations. Backend changes: - Add /api/organizations/{org_id}/usage endpoint in enterprise/server/routes/usage.py - Register usage_router in enterprise/saas_server.py Frontend changes: - Add usage-service API client and types - Add useUsageStats React Query hook - Add Usage nav item to settings navigation (admin/owner only, team orgs only) - Create usage-settings.tsx page with stat cards and conversation chart - Add i18n translations for Usage page (all supported languages) - Add route configuration for /settings/usage Co-authored-by: openhands --- enterprise/saas_server.py | 2 + enterprise/server/routes/usage.py | 200 ++++++++++++++++++ .../api/usage-service/usage-service.api.ts | 11 + frontend/src/api/usage-service/usage.types.ts | 11 + frontend/src/constants/settings-nav.tsx | 7 +- frontend/src/hooks/query/use-usage-stats.ts | 13 ++ frontend/src/hooks/use-settings-nav-items.ts | 5 + frontend/src/i18n/declaration.ts | 9 + frontend/src/i18n/translation.json | 156 +++++++++++++- frontend/src/routes.ts | 1 + frontend/src/routes/usage-settings.tsx | 164 ++++++++++++++ 11 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 enterprise/server/routes/usage.py create mode 100644 frontend/src/api/usage-service/usage-service.api.ts create mode 100644 frontend/src/api/usage-service/usage.types.ts create mode 100644 frontend/src/hooks/query/use-usage-stats.ts create mode 100644 frontend/src/routes/usage-settings.tsx diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index 434652befd..4e485e3d84 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -46,6 +46,7 @@ from server.routes.org_invitations import ( # noqa: E402 ) from server.routes.orgs import org_router # noqa: E402 from server.routes.readiness import readiness_router # noqa: E402 +from server.routes.usage import usage_router # noqa: E402 from server.routes.service import service_router # noqa: E402 from server.routes.user import saas_user_router # noqa: E402 from server.routes.user_app_settings import user_app_settings_router # noqa: E402 @@ -115,6 +116,7 @@ if GITLAB_APP_CLIENT_ID: base_app.include_router(api_keys_router) # Add routes for API key management base_app.include_router(service_router) # Add routes for internal service API base_app.include_router(org_router) # Add routes for organization management +base_app.include_router(usage_router) # Add routes for organization usage statistics base_app.include_router( verified_models_router ) # Add routes for verified models management diff --git a/enterprise/server/routes/usage.py b/enterprise/server/routes/usage.py new file mode 100644 index 0000000000..7f24e60db7 --- /dev/null +++ b/enterprise/server/routes/usage.py @@ -0,0 +1,200 @@ +"""Usage statistics API endpoint for organization dashboards. + +Provides aggregated metrics for organization admins and owners including: +- Total conversation count +- Merged PR count +- Average conversation cost +- Daily conversation counts for the last 90 days +""" + +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from server.auth.authorization import Permission, require_permission +from sqlalchemy import and_, func, select +from storage.database import a_session_maker +from storage.openhands_pr import OpenhandsPR +from storage.stored_conversation_metadata import StoredConversationMetadata +from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas + +from openhands.core.logger import openhands_logger as logger + +usage_router = APIRouter(prefix="/api/organizations", tags=["Usage"]) + + +class DailyConversationCount(BaseModel): + """Daily conversation count for a specific date.""" + + date: str # ISO date format YYYY-MM-DD + count: int + + +class UsageStatsResponse(BaseModel): + """Response model for organization usage statistics.""" + + total_conversations: int + merged_prs: int + average_cost: float + daily_conversations: list[DailyConversationCount] + + +@usage_router.get( + "/{org_id}/usage", + response_model=UsageStatsResponse, + dependencies=[Depends(require_permission(Permission.VIEW_BILLING))], +) +async def get_org_usage_stats( + org_id: UUID, +) -> UsageStatsResponse: + """Get usage statistics for an organization. + + This endpoint returns aggregated usage metrics for the specified organization: + - Total number of conversations + - Number of merged PRs created by OpenHands + - Average cost per conversation + - Daily conversation counts for the last 90 days + + Only users with VIEW_BILLING permission (admin or owner) can access this endpoint. + + Args: + org_id: Organization ID (UUID) + + Returns: + UsageStatsResponse: Aggregated usage statistics + + Raises: + HTTPException: 403 if user doesn't have permission + HTTPException: 500 if retrieval fails + """ + logger.info( + "Fetching usage statistics for organization", + extra={"org_id": str(org_id)}, + ) + + try: + async with a_session_maker() as session: + # Get total conversation count for this org + total_conversations_result = await session.execute( + select( + func.count(StoredConversationMetadataSaas.conversation_id) + ).where(StoredConversationMetadataSaas.org_id == org_id) + ) + total_conversations = total_conversations_result.scalar() or 0 + + # Get average cost - join with conversation metadata to get accumulated_cost + avg_cost_result = await session.execute( + select(func.avg(StoredConversationMetadata.accumulated_cost)) + .select_from(StoredConversationMetadata) + .join( + StoredConversationMetadataSaas, + StoredConversationMetadata.conversation_id + == StoredConversationMetadataSaas.conversation_id, + ) + .where(StoredConversationMetadataSaas.org_id == org_id) + ) + average_cost = avg_cost_result.scalar() or 0.0 + + # Get merged PRs count + # Note: OpenhandsPR doesn't have org_id directly, so we need to join via conversations + # For now, we'll count all merged PRs that are associated with conversations in this org + merged_prs_result = await session.execute( + select(func.count(func.distinct(OpenhandsPR.id))) + .select_from(OpenhandsPR) + .join( + StoredConversationMetadata, + and_( + StoredConversationMetadata.selected_repository + == OpenhandsPR.repo_name, + StoredConversationMetadata.pr_number.contains( + [OpenhandsPR.pr_number] + ), + ), + ) + .join( + StoredConversationMetadataSaas, + StoredConversationMetadata.conversation_id + == StoredConversationMetadataSaas.conversation_id, + ) + .where( + and_( + StoredConversationMetadataSaas.org_id == org_id, + OpenhandsPR.merged.is_(True), + ) + ) + ) + merged_prs = merged_prs_result.scalar() or 0 + + # Get daily conversation counts for the last 90 days + ninety_days_ago = datetime.now(UTC) - timedelta(days=90) + daily_counts_result = await session.execute( + select( + func.date(StoredConversationMetadata.created_at).label("date"), + func.count(StoredConversationMetadata.conversation_id).label( + "count" + ), + ) + .select_from(StoredConversationMetadata) + .join( + StoredConversationMetadataSaas, + StoredConversationMetadata.conversation_id + == StoredConversationMetadataSaas.conversation_id, + ) + .where( + and_( + StoredConversationMetadataSaas.org_id == org_id, + StoredConversationMetadata.created_at >= ninety_days_ago, + ) + ) + .group_by(func.date(StoredConversationMetadata.created_at)) + .order_by(func.date(StoredConversationMetadata.created_at)) + ) + daily_counts_rows = daily_counts_result.all() + + # Convert to response format, filling in missing dates with 0 + daily_conversations = [] + date_counts = { + str(row.date): row.count + for row in daily_counts_rows + if row.date is not None + } + + current_date = ninety_days_ago.date() + end_date = datetime.now(UTC).date() + while current_date <= end_date: + date_str = current_date.isoformat() + daily_conversations.append( + DailyConversationCount( + date=date_str, + count=date_counts.get(date_str, 0), + ) + ) + current_date += timedelta(days=1) + + logger.info( + "Successfully retrieved usage statistics", + extra={ + "org_id": str(org_id), + "total_conversations": total_conversations, + "merged_prs": merged_prs, + "average_cost": average_cost, + }, + ) + + return UsageStatsResponse( + total_conversations=total_conversations, + merged_prs=merged_prs, + average_cost=round(average_cost, 4), + daily_conversations=daily_conversations, + ) + + except Exception as e: + logger.exception( + "Error fetching usage statistics", + extra={"org_id": str(org_id), "error": str(e)}, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve usage statistics", + ) diff --git a/frontend/src/api/usage-service/usage-service.api.ts b/frontend/src/api/usage-service/usage-service.api.ts new file mode 100644 index 0000000000..370e4bb32c --- /dev/null +++ b/frontend/src/api/usage-service/usage-service.api.ts @@ -0,0 +1,11 @@ +import { openHands } from "../open-hands-axios"; +import { UsageStats } from "./usage.types"; + +export const usageService = { + getUsageStats: async ({ orgId }: { orgId: string }): Promise => { + const { data } = await openHands.get( + `/api/organizations/${orgId}/usage`, + ); + return data; + }, +}; diff --git a/frontend/src/api/usage-service/usage.types.ts b/frontend/src/api/usage-service/usage.types.ts new file mode 100644 index 0000000000..98a4d42546 --- /dev/null +++ b/frontend/src/api/usage-service/usage.types.ts @@ -0,0 +1,11 @@ +export interface DailyConversationCount { + date: string; // ISO date format YYYY-MM-DD + count: number; +} + +export interface UsageStats { + total_conversations: number; + merged_prs: number; + average_cost: number; + daily_conversations: DailyConversationCount[]; +} diff --git a/frontend/src/constants/settings-nav.tsx b/frontend/src/constants/settings-nav.tsx index b51deda3e2..35890725fa 100644 --- a/frontend/src/constants/settings-nav.tsx +++ b/frontend/src/constants/settings-nav.tsx @@ -1,4 +1,4 @@ -import { FiUsers, FiBriefcase } from "react-icons/fi"; +import { FiUsers, FiBriefcase, FiBarChart2 } from "react-icons/fi"; import CreditCardIcon from "#/icons/credit-card.svg?react"; import KeyIcon from "#/icons/key.svg?react"; import ServerProcessIcon from "#/icons/server-process.svg?react"; @@ -64,6 +64,11 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [ text: "Organization", icon: , }, + { + to: "/settings/usage", + text: "SETTINGS$NAV_USAGE", + icon: , + }, ]; export const OSS_NAV_ITEMS: SettingsNavItem[] = [ diff --git a/frontend/src/hooks/query/use-usage-stats.ts b/frontend/src/hooks/query/use-usage-stats.ts new file mode 100644 index 0000000000..36ef50e564 --- /dev/null +++ b/frontend/src/hooks/query/use-usage-stats.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { usageService } from "#/api/usage-service/usage-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useUsageStats = () => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["usage-stats", organizationId], + queryFn: () => usageService.getUsageStats({ orgId: organizationId! }), + enabled: !!organizationId, + }); +}; diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts index 236d086ff6..e47faf2fae 100644 --- a/frontend/src/hooks/use-settings-nav-items.ts +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -56,5 +56,10 @@ export function useSettingsNavItems(): SettingsNavItem[] { items = items.filter((item) => item.to !== "/settings/org-members"); } + // Hide usage page for users without view_billing permission or personal orgs + if (!hasPermission("view_billing") || !organizationId || isPersonalOrg) { + items = items.filter((item) => item.to !== "/settings/usage"); + } + return items; } diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 9b355ae432..ff9cd9bb4d 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1198,4 +1198,13 @@ export enum I18nKey { CTA$ENTERPRISE_TITLE = "CTA$ENTERPRISE_TITLE", CTA$ENTERPRISE_DESCRIPTION = "CTA$ENTERPRISE_DESCRIPTION", CTA$LEARN_MORE = "CTA$LEARN_MORE", + SETTINGS$NAV_USAGE = "SETTINGS$NAV_USAGE", + USAGE$TITLE = "USAGE$TITLE", + USAGE$TOTAL_CONVERSATIONS = "USAGE$TOTAL_CONVERSATIONS", + USAGE$MERGED_PRS = "USAGE$MERGED_PRS", + USAGE$AVERAGE_COST = "USAGE$AVERAGE_COST", + USAGE$CONVERSATIONS_OVER_TIME = "USAGE$CONVERSATIONS_OVER_TIME", + USAGE$TOTAL_IN_PERIOD = "USAGE$TOTAL_IN_PERIOD", + USAGE$DAILY_AVERAGE = "USAGE$DAILY_AVERAGE", + USAGE$ERROR_LOADING = "USAGE$ERROR_LOADING", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 57b89cd193..dc27e3e095 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -20367,8 +20367,160 @@ "fr": "En savoir plus", "tr": "Daha fazla bilgi", "de": "Mehr erfahren", - "uk": "Дізнатися більше" - , + "uk": "Дізнатися більше", "ca": "Més informació" + }, + "SETTINGS$NAV_USAGE": { + "en": "Usage", + "ja": "使用状況", + "zh-CN": "使用情况", + "zh-TW": "使用情況", + "ko-KR": "사용량", + "no": "Bruk", + "it": "Utilizzo", + "pt": "Uso", + "es": "Uso", + "ar": "الاستخدام", + "fr": "Utilisation", + "tr": "Kullanım", + "de": "Nutzung", + "uk": "Використання", + "ca": "Ús" + }, + "USAGE$TITLE": { + "en": "Usage", + "ja": "使用状況", + "zh-CN": "使用情况", + "zh-TW": "使用情況", + "ko-KR": "사용량", + "no": "Bruk", + "it": "Utilizzo", + "pt": "Uso", + "es": "Uso", + "ar": "الاستخدام", + "fr": "Utilisation", + "tr": "Kullanım", + "de": "Nutzung", + "uk": "Використання", + "ca": "Ús" + }, + "USAGE$TOTAL_CONVERSATIONS": { + "en": "Total Conversations", + "ja": "会話総数", + "zh-CN": "总对话数", + "zh-TW": "總對話數", + "ko-KR": "총 대화 수", + "no": "Totalt antall samtaler", + "it": "Conversazioni totali", + "pt": "Total de conversas", + "es": "Conversaciones totales", + "ar": "إجمالي المحادثات", + "fr": "Total des conversations", + "tr": "Toplam Konuşmalar", + "de": "Gespräche insgesamt", + "uk": "Всього розмов", + "ca": "Total de converses" + }, + "USAGE$MERGED_PRS": { + "en": "Merged PRs", + "ja": "マージされたPR", + "zh-CN": "已合并的PR", + "zh-TW": "已合併的PR", + "ko-KR": "병합된 PR", + "no": "Sammenslåtte PR-er", + "it": "PR uniti", + "pt": "PRs mesclados", + "es": "PRs fusionados", + "ar": "طلبات السحب المدمجة", + "fr": "PR fusionnées", + "tr": "Birleştirilen PR'lar", + "de": "Zusammengeführte PRs", + "uk": "Об'єднані PR", + "ca": "PRs fusionats" + }, + "USAGE$AVERAGE_COST": { + "en": "Average Cost", + "ja": "平均コスト", + "zh-CN": "平均成本", + "zh-TW": "平均成本", + "ko-KR": "평균 비용", + "no": "Gjennomsnittlig kostnad", + "it": "Costo medio", + "pt": "Custo médio", + "es": "Costo promedio", + "ar": "متوسط التكلفة", + "fr": "Coût moyen", + "tr": "Ortalama Maliyet", + "de": "Durchschnittliche Kosten", + "uk": "Середня вартість", + "ca": "Cost mitjà" + }, + "USAGE$CONVERSATIONS_OVER_TIME": { + "en": "Conversations Over Last 90 Days", + "ja": "過去90日間の会話", + "zh-CN": "过去90天的对话", + "zh-TW": "過去90天的對話", + "ko-KR": "최근 90일간 대화", + "no": "Samtaler de siste 90 dagene", + "it": "Conversazioni negli ultimi 90 giorni", + "pt": "Conversas nos últimos 90 dias", + "es": "Conversaciones en los últimos 90 días", + "ar": "المحادثات خلال آخر 90 يومًا", + "fr": "Conversations des 90 derniers jours", + "tr": "Son 90 Günlük Konuşmalar", + "de": "Gespräche der letzten 90 Tage", + "uk": "Розмови за останні 90 днів", + "ca": "Converses dels últims 90 dies" + }, + "USAGE$TOTAL_IN_PERIOD": { + "en": "Total in period", + "ja": "期間中の合計", + "zh-CN": "期间总数", + "zh-TW": "期間總數", + "ko-KR": "기간 내 총계", + "no": "Totalt i perioden", + "it": "Totale nel periodo", + "pt": "Total no período", + "es": "Total en el período", + "ar": "الإجمالي في الفترة", + "fr": "Total sur la période", + "tr": "Dönem içi toplam", + "de": "Gesamt im Zeitraum", + "uk": "Всього за період", + "ca": "Total en el període" + }, + "USAGE$DAILY_AVERAGE": { + "en": "Daily average", + "ja": "日平均", + "zh-CN": "日均", + "zh-TW": "日均", + "ko-KR": "일일 평균", + "no": "Daglig gjennomsnitt", + "it": "Media giornaliera", + "pt": "Média diária", + "es": "Promedio diario", + "ar": "المتوسط اليومي", + "fr": "Moyenne quotidienne", + "tr": "Günlük ortalama", + "de": "Tagesdurchschnitt", + "uk": "Середнє за день", + "ca": "Mitjana diària" + }, + "USAGE$ERROR_LOADING": { + "en": "Failed to load usage statistics. Please try again later.", + "ja": "使用統計の読み込みに失敗しました。後でもう一度お試しください。", + "zh-CN": "加载使用统计失败。请稍后重试。", + "zh-TW": "載入使用統計失敗。請稍後重試。", + "ko-KR": "사용량 통계를 불러오지 못했습니다. 나중에 다시 시도해 주세요.", + "no": "Kunne ikke laste bruksstatistikk. Prøv igjen senere.", + "it": "Impossibile caricare le statistiche di utilizzo. Riprova più tardi.", + "pt": "Falha ao carregar estatísticas de uso. Tente novamente mais tarde.", + "es": "Error al cargar las estadísticas de uso. Inténtalo de nuevo más tarde.", + "ar": "فشل تحميل إحصائيات الاستخدام. يرجى المحاولة مرة أخرى لاحقًا.", + "fr": "Échec du chargement des statistiques d'utilisation. Veuillez réessayer plus tard.", + "tr": "Kullanım istatistikleri yüklenemedi. Lütfen daha sonra tekrar deneyin.", + "de": "Nutzungsstatistiken konnten nicht geladen werden. Bitte versuchen Sie es später erneut.", + "uk": "Не вдалося завантажити статистику використання. Спробуйте пізніше.", + "ca": "No s'han pogut carregar les estadístiques d'ús. Torneu-ho a provar més tard." } } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index ba401dae9d..1d3c0232b9 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -22,6 +22,7 @@ export default [ route("api-keys", "routes/api-keys.tsx"), route("org-members", "routes/manage-organization-members.tsx"), route("org", "routes/manage-org.tsx"), + route("usage", "routes/usage-settings.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx"), route("microagent-management", "routes/microagent-management.tsx"), diff --git a/frontend/src/routes/usage-settings.tsx b/frontend/src/routes/usage-settings.tsx new file mode 100644 index 0000000000..3c8a215724 --- /dev/null +++ b/frontend/src/routes/usage-settings.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { useUsageStats } from "#/hooks/query/use-usage-stats"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; +import { DailyConversationCount } from "#/api/usage-service/usage.types"; + +export const clientLoader = createPermissionGuard("view_billing"); + +export const handle = { hideTitle: true }; + +interface StatCardProps { + title: string; + value: string | number; + isLoading?: boolean; +} + +function StatCard({ title, value, isLoading }: StatCardProps) { + return ( +
+ {title} + {isLoading ? ( +
+ ) : ( + {value} + )} +
+ ); +} + +interface ConversationChartProps { + data: DailyConversationCount[]; + isLoading?: boolean; +} + +function ConversationChart({ data, isLoading }: ConversationChartProps) { + const { t } = useTranslation(); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + // Group data by week for cleaner display (show last 13 weeks) + const weeklyData: { label: string; count: number }[] = []; + for (let i = 0; i < data.length; i += 7) { + const weekSlice = data.slice(i, i + 7); + const weekTotal = weekSlice.reduce((sum, d) => sum + d.count, 0); + const startDate = weekSlice[0]?.date; + if (startDate) { + const date = new Date(startDate); + const label = date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + weeklyData.push({ label, count: weekTotal }); + } + } + + // Calculate weekly max for scaling + const weeklyMax = Math.max(...weeklyData.map((d) => d.count), 1); + + return ( +
+

+ {t(I18nKey.USAGE$CONVERSATIONS_OVER_TIME)} +

+
+ {/* Y-axis labels and bars */} +
+ {weeklyData.map((week, index) => { + const height = weeklyMax > 0 ? (week.count / weeklyMax) * 100 : 0; + return ( +
+
+
+ ); + })} +
+ {/* X-axis labels */} +
+ {weeklyData.map((week, index) => ( +
+ {index % 2 === 0 ? week.label : ""} +
+ ))} +
+
+ {/* Summary stats */} +
+ + {t(I18nKey.USAGE$TOTAL_IN_PERIOD)}:{" "} + {data.reduce((sum, d) => sum + d.count, 0)} + + + {t(I18nKey.USAGE$DAILY_AVERAGE)}:{" "} + {( + data.reduce((sum, d) => sum + d.count, 0) / Math.max(data.length, 1) + ).toFixed(1)} + +
+
+ ); +} + +function UsageSettings() { + const { t } = useTranslation(); + const { data: usageStats, isLoading, error } = useUsageStats(); + + return ( +
+
+ {t(I18nKey.USAGE$TITLE)} +
+ + {error && ( +
+ {t(I18nKey.USAGE$ERROR_LOADING)} +
+ )} + + {/* Stats Grid */} +
+ + + +
+ + {/* Conversations Chart */} + +
+ ); +} + +export default UsageSettings;