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 <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-03-19 16:32:20 +00:00
parent 120fd7516a
commit 93fe1613d7
11 changed files with 576 additions and 3 deletions

View File

@@ -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

View File

@@ -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",
)

View File

@@ -0,0 +1,11 @@
import { openHands } from "../open-hands-axios";
import { UsageStats } from "./usage.types";
export const usageService = {
getUsageStats: async ({ orgId }: { orgId: string }): Promise<UsageStats> => {
const { data } = await openHands.get<UsageStats>(
`/api/organizations/${orgId}/usage`,
);
return data;
},
};

View File

@@ -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[];
}

View File

@@ -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: <FiBriefcase size={22} />,
},
{
to: "/settings/usage",
text: "SETTINGS$NAV_USAGE",
icon: <FiBarChart2 size={22} />,
},
];
export const OSS_NAV_ITEMS: SettingsNavItem[] = [

View File

@@ -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,
});
};

View File

@@ -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;
}

View File

@@ -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",
}

View File

@@ -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."
}
}

View File

@@ -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"),

View File

@@ -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 (
<div className="rounded-xl border border-org-border bg-org-background p-6 flex flex-col gap-2">
<span className="text-xs text-tertiary-alt font-medium">{title}</span>
{isLoading ? (
<div className="h-8 w-24 bg-tertiary animate-pulse rounded" />
) : (
<span className="text-2xl font-bold text-white">{value}</span>
)}
</div>
);
}
interface ConversationChartProps {
data: DailyConversationCount[];
isLoading?: boolean;
}
function ConversationChart({ data, isLoading }: ConversationChartProps) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="rounded-xl border border-org-border bg-org-background p-6">
<div className="h-6 w-48 bg-tertiary animate-pulse rounded mb-4" />
<div className="h-64 bg-tertiary animate-pulse rounded" />
</div>
);
}
// 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 (
<div className="rounded-xl border border-org-border bg-org-background p-6">
<h3 className="text-sm font-medium text-white mb-4">
{t(I18nKey.USAGE$CONVERSATIONS_OVER_TIME)}
</h3>
<div className="flex flex-col gap-2">
{/* Y-axis labels and bars */}
<div className="flex items-end gap-1 h-48">
{weeklyData.map((week, index) => {
const height = weeklyMax > 0 ? (week.count / weeklyMax) * 100 : 0;
return (
<div
key={index}
className="flex-1 flex flex-col items-center justify-end h-full"
>
<div
className="w-full bg-primary rounded-t transition-all duration-300 min-h-[2px]"
style={{ height: `${Math.max(height, 2)}%` }}
title={`${week.label}: ${week.count} conversations`}
/>
</div>
);
})}
</div>
{/* X-axis labels */}
<div className="flex gap-1 text-[10px] text-tertiary-alt overflow-hidden">
{weeklyData.map((week, index) => (
<div key={index} className="flex-1 text-center truncate">
{index % 2 === 0 ? week.label : ""}
</div>
))}
</div>
</div>
{/* Summary stats */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-org-divider text-xs text-tertiary-alt">
<span>
{t(I18nKey.USAGE$TOTAL_IN_PERIOD)}:{" "}
{data.reduce((sum, d) => sum + d.count, 0)}
</span>
<span>
{t(I18nKey.USAGE$DAILY_AVERAGE)}:{" "}
{(
data.reduce((sum, d) => sum + d.count, 0) / Math.max(data.length, 1)
).toFixed(1)}
</span>
</div>
</div>
);
}
function UsageSettings() {
const { t } = useTranslation();
const { data: usageStats, isLoading, error } = useUsageStats();
return (
<div data-testid="usage-settings" className="flex flex-col gap-6 h-full">
<div className="flex items-center justify-between">
<Typography.H2>{t(I18nKey.USAGE$TITLE)}</Typography.H2>
</div>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-red-400">
{t(I18nKey.USAGE$ERROR_LOADING)}
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title={t(I18nKey.USAGE$TOTAL_CONVERSATIONS)}
value={usageStats?.total_conversations ?? 0}
isLoading={isLoading}
/>
<StatCard
title={t(I18nKey.USAGE$MERGED_PRS)}
value={usageStats?.merged_prs ?? 0}
isLoading={isLoading}
/>
<StatCard
title={t(I18nKey.USAGE$AVERAGE_COST)}
value={
usageStats ? `$${usageStats.average_cost.toFixed(2)}` : "$0.00"
}
isLoading={isLoading}
/>
</div>
{/* Conversations Chart */}
<ConversationChart
data={usageStats?.daily_conversations ?? []}
isLoading={isLoading}
/>
</div>
);
}
export default UsageSettings;