Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced hardcoded "Agenda" labels with dynamic ULTICAL_APP_NAME in various components for consistency. - Introduced new AiUsageSection and CompteAiUsageSection components to track AI usage and costs. - Updated settings and metadata to reflect changes in AI cost policies and usage limits. - Enhanced user interface elements for better accessibility and user experience across admin settings.
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import Link from "next/link"
|
|
import { useMemo } from "react"
|
|
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
|
import type { ComponentType } from "react"
|
|
import { CalendarDays, Coins, Gauge, TrendingUp } from "lucide-react"
|
|
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
|
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
|
import { SettingsCard } from "@/components/settings/settings-kit"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
ChartContainer,
|
|
ChartTooltip,
|
|
ChartTooltipContent,
|
|
type ChartConfig,
|
|
} from "@/components/ui/chart"
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table"
|
|
import {
|
|
useAdminAIUsage,
|
|
useAdminAIPricing,
|
|
} from "@/lib/api/hooks/use-admin-ai-usage"
|
|
import { formatAiCostEUR } from "@/lib/api/hooks/use-ai-queries"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const chartConfig = {
|
|
cost: {
|
|
label: "Coût org",
|
|
color: "hsl(var(--primary))",
|
|
},
|
|
} satisfies ChartConfig
|
|
|
|
function formatPricePerMTok(eur: number): string {
|
|
return new Intl.NumberFormat("fr-FR", {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(eur)
|
|
}
|
|
|
|
function formatShortDate(isoDate: string): string {
|
|
const date = new Date(`${isoDate}T12:00:00`)
|
|
return date.toLocaleDateString("fr-FR", { day: "numeric", month: "short" })
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
hint,
|
|
icon: Icon,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
hint?: string
|
|
icon: ComponentType<{ className?: string }>
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-mail-border bg-mail-surface p-5 shadow-sm dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">{value}</p>
|
|
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
|
|
</div>
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
<Icon className="size-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RankedRow({
|
|
rank,
|
|
label,
|
|
sublabel,
|
|
value,
|
|
sharePct,
|
|
}: {
|
|
rank: number
|
|
label: string
|
|
sublabel?: string
|
|
value: string
|
|
sharePct: number
|
|
}) {
|
|
return (
|
|
<div className="flex items-start gap-3 py-2.5">
|
|
<span className="mt-0.5 w-5 shrink-0 text-xs font-medium tabular-nums text-muted-foreground">
|
|
{rank}
|
|
</span>
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{label}</p>
|
|
{sublabel ? (
|
|
<p className="truncate text-xs text-muted-foreground">{sublabel}</p>
|
|
) : null}
|
|
</div>
|
|
<span className="shrink-0 text-sm tabular-nums font-medium">{value}</span>
|
|
</div>
|
|
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary/80 transition-all"
|
|
style={{ width: `${Math.max(sharePct, 4)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptyPanel({ message }: { message: string }) {
|
|
return (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">{message}</p>
|
|
)
|
|
}
|
|
|
|
export function AiUsageSection() {
|
|
const { data, isFetching, isError, refetch } = useAdminAIUsage("org")
|
|
const { data: pricing } = useAdminAIPricing()
|
|
|
|
const dailySeries = data?.daily_series ?? []
|
|
const topUsers = data?.top_users ?? []
|
|
const topModels = data?.top_models ?? []
|
|
const pricingRows = pricing ?? []
|
|
|
|
const chartData = useMemo(
|
|
() =>
|
|
dailySeries.map((day) => ({
|
|
date: day.date,
|
|
label: formatShortDate(day.date),
|
|
cost: day.cost_org_micro_eur / 1_000_000,
|
|
requests: day.requests,
|
|
})),
|
|
[dailySeries]
|
|
)
|
|
|
|
const maxUserCost = useMemo(
|
|
() => Math.max(...topUsers.map((u) => u.cost_org_micro_eur), 1),
|
|
[topUsers]
|
|
)
|
|
const maxModelCost = useMemo(
|
|
() => Math.max(...topModels.map((m) => m.cost_micro_eur), 1),
|
|
[topModels]
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<SettingsSectionHeader
|
|
title="Usage IA"
|
|
description="Coûts estimés de la consommation LLM (clés organisation) et tarifs modèles."
|
|
/>
|
|
<SettingsSyncBanner
|
|
isFetching={isFetching}
|
|
isError={isError}
|
|
onRetry={() => refetch()}
|
|
/>
|
|
|
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href="/admin/settings/quotas">
|
|
<Gauge className="mr-2 size-4" />
|
|
Modifier les plafonds
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{data ? (
|
|
<div className="space-y-6">
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<StatCard
|
|
label="Aujourd'hui (org)"
|
|
value={formatAiCostEUR(data.cost_today_micro_eur)}
|
|
icon={CalendarDays}
|
|
/>
|
|
<StatCard
|
|
label="Ce mois (org)"
|
|
value={formatAiCostEUR(data.cost_month_micro_eur)}
|
|
icon={TrendingUp}
|
|
/>
|
|
<StatCard
|
|
label="Devise"
|
|
value={data.currency}
|
|
hint="Estimation basée sur les tarifs modèles"
|
|
icon={Coins}
|
|
/>
|
|
</div>
|
|
|
|
{dailySeries.length > 0 ? (
|
|
<SettingsCard
|
|
title="30 derniers jours"
|
|
description="Coût quotidien estimé pour les clés organisation."
|
|
>
|
|
<ChartContainer config={chartConfig} className="aspect-3/1 h-48 w-full">
|
|
<BarChart data={chartData} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
|
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="label"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
interval="preserveStartEnd"
|
|
minTickGap={28}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={48}
|
|
tickFormatter={(v: number) =>
|
|
new Intl.NumberFormat("fr-FR", {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
notation: "compact",
|
|
maximumFractionDigits: 1,
|
|
}).format(v)
|
|
}
|
|
/>
|
|
<ChartTooltip
|
|
cursor={{ fill: "hsl(var(--muted))", opacity: 0.35 }}
|
|
content={
|
|
<ChartTooltipContent
|
|
labelFormatter={(_, payload) => {
|
|
const row = payload?.[0]?.payload as { date?: string } | undefined
|
|
return row?.date
|
|
? new Date(`${row.date}T12:00:00`).toLocaleDateString("fr-FR", {
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "long",
|
|
})
|
|
: ""
|
|
}}
|
|
formatter={(value, _name, item) => {
|
|
const requests = (item.payload as { requests?: number }).requests ?? 0
|
|
return (
|
|
<div className="flex w-full items-center justify-between gap-4">
|
|
<span>{chartConfig.cost.label}</span>
|
|
<span className="font-mono tabular-nums">
|
|
{formatAiCostEUR(Number(value) * 1_000_000)}
|
|
{requests > 0 ? ` · ${requests} req.` : ""}
|
|
</span>
|
|
</div>
|
|
)
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey="cost" fill="var(--color-cost)" radius={[3, 3, 0, 0]} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
</SettingsCard>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<SettingsCard title="Top utilisateurs (mois)" description="Clés organisation uniquement.">
|
|
{topUsers.length === 0 ? (
|
|
<EmptyPanel message="Aucune consommation enregistrée" />
|
|
) : (
|
|
<div className="divide-y divide-mail-border">
|
|
{topUsers.map((u, index) => (
|
|
<RankedRow
|
|
key={u.user_id}
|
|
rank={index + 1}
|
|
label={u.display_name || u.email}
|
|
sublabel={u.display_name ? u.email : undefined}
|
|
value={formatAiCostEUR(u.cost_org_micro_eur)}
|
|
sharePct={Math.round((u.cost_org_micro_eur / maxUserCost) * 100)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SettingsCard>
|
|
|
|
<SettingsCard title="Top modèles (mois)" description="Répartition par modèle LLM.">
|
|
{topModels.length === 0 ? (
|
|
<EmptyPanel message="Aucune consommation enregistrée" />
|
|
) : (
|
|
<div className="divide-y divide-mail-border">
|
|
{topModels.map((m, index) => (
|
|
<RankedRow
|
|
key={m.model_id}
|
|
rank={index + 1}
|
|
label={m.model_id}
|
|
sublabel={`${m.request_count} requête${m.request_count > 1 ? "s" : ""}`}
|
|
value={formatAiCostEUR(m.cost_micro_eur)}
|
|
sharePct={Math.round((m.cost_micro_eur / maxModelCost) * 100)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SettingsCard>
|
|
</div>
|
|
|
|
{pricingRows.length > 0 ? (
|
|
<SettingsCard
|
|
title="Tarifs modèles"
|
|
description="Prix de référence utilisés pour estimer les coûts."
|
|
>
|
|
<div className="overflow-x-auto rounded-lg border border-mail-border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Modèle</TableHead>
|
|
<TableHead className="text-right">
|
|
Input <span className="font-normal text-muted-foreground">/ 1M</span>
|
|
</TableHead>
|
|
<TableHead className="text-right">
|
|
Output <span className="font-normal text-muted-foreground">/ 1M</span>
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pricingRows.map((p) => (
|
|
<TableRow key={p.model_id}>
|
|
<TableCell>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-mono text-xs">{p.model_id}</span>
|
|
{p.provider_type ? (
|
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
|
{p.provider_type}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right tabular-nums">
|
|
{formatPricePerMTok(p.input_eur_per_mtok)}
|
|
</TableCell>
|
|
<TableCell className="text-right tabular-nums">
|
|
{formatPricePerMTok(p.output_eur_per_mtok)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SettingsCard>
|
|
) : null}
|
|
</div>
|
|
) : isFetching ? (
|
|
<p className={cn("text-sm text-muted-foreground")}>Chargement de la consommation…</p>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|