mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
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:
@@ -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
|
||||
|
||||
200
enterprise/server/routes/usage.py
Normal file
200
enterprise/server/routes/usage.py
Normal 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",
|
||||
)
|
||||
11
frontend/src/api/usage-service/usage-service.api.ts
Normal file
11
frontend/src/api/usage-service/usage-service.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
11
frontend/src/api/usage-service/usage.types.ts
Normal file
11
frontend/src/api/usage-service/usage.types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
13
frontend/src/hooks/query/use-usage-stats.ts
Normal file
13
frontend/src/hooks/query/use-usage-stats.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
164
frontend/src/routes/usage-settings.tsx
Normal file
164
frontend/src/routes/usage-settings.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user