* refactor: codex usage ui * feat: show Codex reset credit details * feat: add Codex usage reset flow
1273 lines
39 KiB
TypeScript
Vendored
1273 lines
39 KiB
TypeScript
Vendored
/*
|
||
Copyright (C) 2023-2026 QuantumNous
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as
|
||
published by the Free Software Foundation, either version 3 of the
|
||
License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
For commercial licensing, please contact support@quantumnous.com
|
||
*/
|
||
import { type ReactNode, useCallback, useMemo, useState } from 'react'
|
||
import {
|
||
Copy,
|
||
Check,
|
||
RefreshCw,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
RotateCcw,
|
||
AlertTriangle,
|
||
} from 'lucide-react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import dayjs from '@/lib/dayjs'
|
||
import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format'
|
||
import { cn } from '@/lib/utils'
|
||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
Card,
|
||
CardAction,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '@/components/ui/card'
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from '@/components/ui/collapsible'
|
||
import {
|
||
Empty,
|
||
EmptyDescription,
|
||
EmptyHeader,
|
||
EmptyTitle,
|
||
} from '@/components/ui/empty'
|
||
import { Progress } from '@/components/ui/progress'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||
import { Dialog } from '@/components/dialog'
|
||
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
||
import {
|
||
getCodexResetCredits,
|
||
resetCodexUsage,
|
||
type CodexResetCreditsResponse,
|
||
} from '../../api'
|
||
|
||
type CodexRateLimitWindow = {
|
||
used_percent?: number
|
||
reset_at?: number
|
||
reset_after_seconds?: number
|
||
limit_window_seconds?: number
|
||
}
|
||
|
||
type CodexRateLimit = {
|
||
plan_type?: string
|
||
allowed?: boolean
|
||
limit_reached?: boolean
|
||
primary_window?: CodexRateLimitWindow
|
||
secondary_window?: CodexRateLimitWindow
|
||
}
|
||
|
||
type CodexAdditionalRateLimit = {
|
||
limit_name?: string
|
||
metered_feature?: string
|
||
rate_limit?: CodexRateLimit
|
||
primary_window?: CodexRateLimitWindow
|
||
secondary_window?: CodexRateLimitWindow
|
||
plan_type?: string
|
||
}
|
||
|
||
type CodexResetCredit = {
|
||
id?: string
|
||
reset_type?: string
|
||
status?: string
|
||
granted_at?: string | null
|
||
expires_at?: string | null
|
||
redeem_started_at?: string | null
|
||
redeemed_at?: string | null
|
||
profile_image_url?: string
|
||
profile_user_id?: string
|
||
title?: string
|
||
description?: string
|
||
}
|
||
|
||
type CodexResetCreditsPayload = {
|
||
credits?: CodexResetCredit[]
|
||
available_count?: number
|
||
total_earned_count?: number
|
||
}
|
||
|
||
type CodexUsagePayload = {
|
||
plan_type?: string
|
||
user_id?: string
|
||
email?: string
|
||
rate_limit?: CodexRateLimit
|
||
additional_rate_limits?: CodexAdditionalRateLimit[]
|
||
rate_limit_reset_credits?: {
|
||
available_count?: number
|
||
}
|
||
credits?: {
|
||
overage_limit_reached?: boolean
|
||
}
|
||
spend_control?: {
|
||
reached?: boolean
|
||
}
|
||
}
|
||
|
||
export type CodexUsageDialogData = {
|
||
success: boolean
|
||
message?: string
|
||
upstream_status?: number
|
||
data?: Record<string, unknown>
|
||
}
|
||
|
||
type CodexUsageDialogProps = {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
channelName?: string
|
||
channelId?: number
|
||
response: CodexUsageDialogData | null
|
||
onRefresh?: () => void | Promise<void>
|
||
isRefreshing?: boolean
|
||
}
|
||
|
||
function clampPercent(value: unknown): number {
|
||
const v = Number(value)
|
||
return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0
|
||
}
|
||
|
||
function formatUnixSeconds(unixSeconds: unknown): string {
|
||
const v = Number(unixSeconds)
|
||
return Number.isFinite(v) && v > 0 ? formatTimestampToDate(v) : '-'
|
||
}
|
||
|
||
function formatIsoTimestamp(value: unknown): string {
|
||
if (typeof value !== 'string' || value.trim() === '') return '-'
|
||
const d = dayjs(value)
|
||
if (!d.isValid()) return value
|
||
return formatDateTimeStr(d.toDate())
|
||
}
|
||
|
||
function formatDurationSeconds(
|
||
seconds: unknown,
|
||
t: (key: string) => string
|
||
): string {
|
||
const s = Number(seconds)
|
||
if (!Number.isFinite(s) || s <= 0) return '-'
|
||
|
||
const total = Math.floor(s)
|
||
const hours = Math.floor(total / 3600)
|
||
const minutes = Math.floor((total % 3600) / 60)
|
||
const secs = total % 60
|
||
|
||
if (hours > 0) return `${hours}${t('h')} ${minutes}${t('m')}`
|
||
if (minutes > 0) return `${minutes}${t('m')} ${secs}${t('s')}`
|
||
return `${secs}${t('s')}`
|
||
}
|
||
|
||
function formatTimeLeftUntil(
|
||
value: unknown,
|
||
t: (key: string) => string
|
||
): string {
|
||
if (typeof value !== 'string' || value.trim() === '') return '-'
|
||
const expiresAt = dayjs(value)
|
||
if (!expiresAt.isValid()) return '-'
|
||
|
||
const secondsLeft = expiresAt.diff(dayjs(), 'second')
|
||
if (secondsLeft <= 0) return t('Expired')
|
||
|
||
const days = Math.floor(secondsLeft / (24 * 60 * 60))
|
||
const remainingSeconds = secondsLeft % (24 * 60 * 60)
|
||
if (days > 0) {
|
||
const hours = Math.floor(remainingSeconds / 3600)
|
||
return `${days} ${t('days')} ${hours}${t('h')}`
|
||
}
|
||
|
||
return formatDurationSeconds(secondsLeft, t)
|
||
}
|
||
|
||
function normalizePlanType(value: unknown): string {
|
||
if (value == null) return ''
|
||
return String(value).trim().toLowerCase()
|
||
}
|
||
|
||
function parseTimeValue(value: unknown): number {
|
||
if (typeof value !== 'string' || value.trim() === '') {
|
||
return Number.POSITIVE_INFINITY
|
||
}
|
||
const d = dayjs(value)
|
||
return d.isValid() ? d.valueOf() : Number.POSITIVE_INFINITY
|
||
}
|
||
|
||
function normalizeResetCreditStatus(value: unknown): string {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
}
|
||
|
||
function sortResetCredits(credits: CodexResetCredit[]): CodexResetCredit[] {
|
||
return [...credits].sort((a, b) => {
|
||
const aAvailable = normalizeResetCreditStatus(a.status) === 'available'
|
||
const bAvailable = normalizeResetCreditStatus(b.status) === 'available'
|
||
if (aAvailable !== bAvailable) return aAvailable ? -1 : 1
|
||
|
||
const expiresDiff =
|
||
parseTimeValue(a.expires_at) - parseTimeValue(b.expires_at)
|
||
if (expiresDiff !== 0) return expiresDiff
|
||
|
||
const grantedDiff =
|
||
parseTimeValue(a.granted_at) - parseTimeValue(b.granted_at)
|
||
if (grantedDiff !== 0) return grantedDiff
|
||
|
||
return String(a.id || '').localeCompare(String(b.id || ''))
|
||
})
|
||
}
|
||
|
||
function classifyWindowByDuration(
|
||
windowData?: CodexRateLimitWindow | null
|
||
): 'weekly' | 'fiveHour' | null {
|
||
const seconds = Number(windowData?.limit_window_seconds)
|
||
if (!Number.isFinite(seconds) || seconds <= 0) return null
|
||
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour'
|
||
}
|
||
|
||
type RateLimitSource = {
|
||
plan_type?: string
|
||
rate_limit?: CodexRateLimit
|
||
}
|
||
|
||
function resolveRateLimitWindows(data: RateLimitSource | null): {
|
||
fiveHourWindow: CodexRateLimitWindow | null
|
||
weeklyWindow: CodexRateLimitWindow | null
|
||
} {
|
||
const rateLimit = data?.rate_limit ?? {}
|
||
const primary = rateLimit?.primary_window ?? null
|
||
const secondary = rateLimit?.secondary_window ?? null
|
||
const windows = [primary, secondary].filter(Boolean) as CodexRateLimitWindow[]
|
||
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type)
|
||
|
||
let fiveHourWindow: CodexRateLimitWindow | null = null
|
||
let weeklyWindow: CodexRateLimitWindow | null = null
|
||
|
||
for (const w of windows) {
|
||
const bucket = classifyWindowByDuration(w)
|
||
if (bucket === 'fiveHour' && !fiveHourWindow) {
|
||
fiveHourWindow = w
|
||
continue
|
||
}
|
||
if (bucket === 'weekly' && !weeklyWindow) {
|
||
weeklyWindow = w
|
||
}
|
||
}
|
||
|
||
if (planType === 'free') {
|
||
if (!weeklyWindow) weeklyWindow = primary ?? secondary ?? null
|
||
return { fiveHourWindow: null, weeklyWindow }
|
||
}
|
||
|
||
if (!fiveHourWindow && !weeklyWindow) {
|
||
return { fiveHourWindow: primary, weeklyWindow: secondary }
|
||
}
|
||
|
||
if (!fiveHourWindow) {
|
||
fiveHourWindow = windows.find((w) => w !== weeklyWindow) ?? null
|
||
}
|
||
if (!weeklyWindow) {
|
||
weeklyWindow = windows.find((w) => w !== fiveHourWindow) ?? null
|
||
}
|
||
|
||
return { fiveHourWindow, weeklyWindow }
|
||
}
|
||
|
||
const PLAN_TYPE_BADGE: Record<
|
||
string,
|
||
{ label: string; variant: StatusBadgeProps['variant'] }
|
||
> = {
|
||
enterprise: { label: 'Enterprise', variant: 'success' },
|
||
team: { label: 'Team', variant: 'info' },
|
||
pro: { label: 'Pro', variant: 'blue' },
|
||
plus: { label: 'Plus', variant: 'purple' },
|
||
free: { label: 'Free', variant: 'warning' },
|
||
}
|
||
|
||
const RESET_CREDIT_STATUS_BADGE: Record<
|
||
string,
|
||
{ label: string; variant: StatusBadgeProps['variant'] }
|
||
> = {
|
||
available: { label: 'Available', variant: 'success' },
|
||
redeemed: { label: 'Redeemed', variant: 'neutral' },
|
||
expired: { label: 'Expired', variant: 'warning' },
|
||
}
|
||
|
||
function getAccountTypeBadge(
|
||
value: unknown,
|
||
t: (key: string) => string
|
||
): { label: string; variant: StatusBadgeProps['variant'] } {
|
||
const normalized = normalizePlanType(value)
|
||
return (
|
||
PLAN_TYPE_BADGE[normalized] ?? {
|
||
label: String(value || '') || t('Unknown'),
|
||
variant: 'neutral' as const,
|
||
}
|
||
)
|
||
}
|
||
|
||
function getResetCreditStatusBadge(
|
||
value: unknown,
|
||
t: (key: string) => string
|
||
): { label: string; variant: StatusBadgeProps['variant'] } {
|
||
const normalized = normalizeResetCreditStatus(value)
|
||
return (
|
||
RESET_CREDIT_STATUS_BADGE[normalized] ?? {
|
||
label: String(value || '') || t('Unknown'),
|
||
variant: 'neutral' as const,
|
||
}
|
||
)
|
||
}
|
||
|
||
function windowLabel(windowData?: CodexRateLimitWindow | null) {
|
||
const percent = clampPercent(windowData?.used_percent)
|
||
const variant: StatusBadgeProps['variant'] =
|
||
percent >= 95 ? 'danger' : percent >= 80 ? 'warning' : 'info'
|
||
return { percent, variant }
|
||
}
|
||
|
||
function getUsageStatusBadge(
|
||
rateLimit: CodexRateLimit | undefined,
|
||
t: (key: string) => string
|
||
) {
|
||
if (!rateLimit || Object.keys(rateLimit).length === 0) {
|
||
return (
|
||
<StatusBadge label={t('Pending')} variant='neutral' copyable={false} />
|
||
)
|
||
}
|
||
if (rateLimit.allowed && !rateLimit.limit_reached) {
|
||
return (
|
||
<StatusBadge label={t('Available')} variant='success' copyable={false} />
|
||
)
|
||
}
|
||
return <StatusBadge label={t('Limited')} variant='danger' copyable={false} />
|
||
}
|
||
|
||
function formatLabelValue(label: string, value: string) {
|
||
return label.endsWith(':') ? `${label}${value}` : `${label} ${value}`
|
||
}
|
||
|
||
const percentTextClassName: Record<
|
||
NonNullable<StatusBadgeProps['variant']>,
|
||
string
|
||
> = {
|
||
success: 'text-success',
|
||
warning: 'text-warning',
|
||
danger: 'text-destructive',
|
||
info: 'text-info',
|
||
neutral: 'text-muted-foreground',
|
||
purple: 'text-chart-4',
|
||
amber: 'text-warning',
|
||
blue: 'text-chart-1',
|
||
cyan: 'text-chart-2',
|
||
green: 'text-success',
|
||
grey: 'text-muted-foreground',
|
||
indigo: 'text-chart-1',
|
||
'light-blue': 'text-info',
|
||
'light-green': 'text-success',
|
||
lime: 'text-chart-3',
|
||
orange: 'text-warning',
|
||
pink: 'text-chart-5',
|
||
red: 'text-destructive',
|
||
teal: 'text-chart-2',
|
||
violet: 'text-chart-4',
|
||
yellow: 'text-warning',
|
||
}
|
||
|
||
type RateLimitWindowProps = {
|
||
title: string
|
||
window?: CodexRateLimitWindow | null
|
||
}
|
||
|
||
function RateLimitWindow(props: RateLimitWindowProps) {
|
||
const { t } = useTranslation()
|
||
const hasData =
|
||
!!props.window &&
|
||
typeof props.window === 'object' &&
|
||
Object.keys(props.window).length > 0
|
||
const { percent, variant } = windowLabel(props.window)
|
||
|
||
return (
|
||
<Card size='sm' className='gap-0 py-0'>
|
||
<CardHeader className='p-3 pb-2'>
|
||
<div className='flex items-start justify-between gap-3'>
|
||
<div className='min-w-0'>
|
||
<CardTitle className='text-sm font-semibold'>
|
||
{props.title}
|
||
</CardTitle>
|
||
<CardDescription className='mt-1 text-xs'>
|
||
{t('Window:')}{' '}
|
||
{hasData
|
||
? formatDurationSeconds(props.window?.limit_window_seconds, t)
|
||
: '-'}
|
||
</CardDescription>
|
||
</div>
|
||
<div className='shrink-0 text-right'>
|
||
<div
|
||
className={cn(
|
||
'text-xl leading-none font-semibold tabular-nums',
|
||
percentTextClassName[variant ?? 'neutral']
|
||
)}
|
||
>
|
||
{hasData ? `${percent}%` : '-'}
|
||
</div>
|
||
<div className='text-muted-foreground mt-1 text-[11px]'>
|
||
{t('Used')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className='p-3 pt-0'>
|
||
{hasData ? (
|
||
<Progress
|
||
value={percent}
|
||
aria-label={`${props.title} usage: ${percent}%`}
|
||
className='mt-1'
|
||
/>
|
||
) : (
|
||
<div className='text-muted-foreground mt-1 text-sm'>-</div>
|
||
)}
|
||
<div className='mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-2'>
|
||
<div className='min-w-0'>
|
||
<div className='text-muted-foreground text-[11px]'>
|
||
{t('Reset at:')}
|
||
</div>
|
||
<div className='break-all tabular-nums'>
|
||
{hasData ? formatUnixSeconds(props.window?.reset_at) : '-'}
|
||
</div>
|
||
</div>
|
||
<div className='min-w-0 sm:text-right'>
|
||
<div className='text-muted-foreground text-[11px]'>
|
||
{t('Resets in:')}
|
||
</div>
|
||
<div className='tabular-nums'>
|
||
{hasData
|
||
? formatDurationSeconds(props.window?.reset_after_seconds, t)
|
||
: '-'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function RateLimitWindowGrid(props: {
|
||
fiveHourWindow?: CodexRateLimitWindow | null
|
||
weeklyWindow?: CodexRateLimitWindow | null
|
||
}) {
|
||
const { t } = useTranslation()
|
||
|
||
return (
|
||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||
<RateLimitWindow
|
||
title={t('5-Hour Window')}
|
||
window={props.fiveHourWindow}
|
||
/>
|
||
<RateLimitWindow title={t('Weekly Window')} window={props.weeklyWindow} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SectionHeading(props: {
|
||
title: string
|
||
description?: string
|
||
children?: ReactNode
|
||
}) {
|
||
return (
|
||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||
<div className='min-w-0'>
|
||
<div className='text-sm font-semibold'>{props.title}</div>
|
||
{props.description ? (
|
||
<div className='text-muted-foreground mt-1 text-xs leading-5'>
|
||
{props.description}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{props.children ? (
|
||
<div className='flex shrink-0 flex-wrap items-center gap-2'>
|
||
{props.children}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type RateLimitGroupSectionProps = {
|
||
title: string
|
||
description?: string
|
||
source: RateLimitSource | null
|
||
meteredFeature?: string
|
||
}
|
||
|
||
function RateLimitGroupSection(props: RateLimitGroupSectionProps) {
|
||
const { t } = useTranslation()
|
||
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(props.source)
|
||
const statusBadge = getUsageStatusBadge(props.source?.rate_limit, t)
|
||
|
||
return (
|
||
<section className='bg-muted/40 flex flex-col gap-3 rounded-xl p-3'>
|
||
<SectionHeading title={props.title} description={props.description}>
|
||
{statusBadge}
|
||
</SectionHeading>
|
||
{props.meteredFeature ? (
|
||
<div className='bg-background ring-border/60 inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 rounded-lg px-2 py-1 text-xs ring-1'>
|
||
<span className='text-muted-foreground text-[11px]'>
|
||
metered_feature
|
||
</span>
|
||
<span className='min-w-0 font-mono break-all'>
|
||
{props.meteredFeature}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
<RateLimitWindowGrid
|
||
fiveHourWindow={fiveHourWindow}
|
||
weeklyWindow={weeklyWindow}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function InfoField(props: {
|
||
label: string
|
||
value?: string | null
|
||
mono?: boolean
|
||
copyable?: boolean
|
||
className?: string
|
||
}) {
|
||
const { t } = useTranslation()
|
||
const { copyToClipboard, copiedText } = useCopyToClipboard({ notify: false })
|
||
const text = props.value?.trim() || ''
|
||
const hasCopied = copiedText === text
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'bg-background ring-border/60 min-w-0 rounded-lg p-3 ring-1',
|
||
props.className
|
||
)}
|
||
>
|
||
<div className='text-muted-foreground text-[11px] font-medium'>
|
||
{props.label}
|
||
</div>
|
||
<div className='mt-1 flex min-w-0 items-start justify-between gap-2'>
|
||
<span
|
||
className={cn(
|
||
'min-w-0 flex-1 text-xs leading-5 break-all',
|
||
props.mono && 'font-mono tabular-nums'
|
||
)}
|
||
>
|
||
{text || '-'}
|
||
</span>
|
||
{props.copyable !== false && text ? (
|
||
<Button
|
||
type='button'
|
||
variant='ghost'
|
||
size='icon-xs'
|
||
aria-label={t('Copy')}
|
||
onClick={() => copyToClipboard(text)}
|
||
>
|
||
{hasCopied ? <Check className='text-success' /> : <Copy />}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ResetCreditTimeField(props: {
|
||
label: string
|
||
value: string
|
||
emphasis?: boolean
|
||
}) {
|
||
return (
|
||
<div className='min-w-0'>
|
||
<div className='text-muted-foreground text-[11px] font-medium'>
|
||
{props.label}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
'mt-1 text-xs leading-5 tabular-nums',
|
||
props.emphasis ? 'font-semibold' : 'text-foreground'
|
||
)}
|
||
>
|
||
{props.value}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ResetCreditItem(props: { credit: CodexResetCredit; index: number }) {
|
||
const { t } = useTranslation()
|
||
const statusBadge = getResetCreditStatusBadge(props.credit.status, t)
|
||
const title =
|
||
props.credit.title?.trim() || `${t('Reset Credit')} ${props.index + 1}`
|
||
const expiresIn = formatTimeLeftUntil(props.credit.expires_at, t)
|
||
const isAvailable =
|
||
normalizeResetCreditStatus(props.credit.status) === 'available'
|
||
|
||
return (
|
||
<div className='bg-background rounded-lg border p-3'>
|
||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||
<div className='min-w-0'>
|
||
<div className='flex flex-wrap items-center gap-2'>
|
||
<div className='min-w-0 text-sm font-medium break-words'>
|
||
{title}
|
||
</div>
|
||
<StatusBadge
|
||
label={t(statusBadge.label)}
|
||
variant={statusBadge.variant}
|
||
copyable={false}
|
||
/>
|
||
</div>
|
||
{props.credit.description ? (
|
||
<div className='text-muted-foreground mt-1 text-xs leading-5'>
|
||
{props.credit.description}
|
||
</div>
|
||
) : null}
|
||
{props.credit.id ? (
|
||
<div className='text-muted-foreground mt-1 font-mono text-[11px] break-all'>
|
||
{props.credit.id}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className='shrink-0 text-right'>
|
||
<div className='text-muted-foreground text-[11px] font-medium'>
|
||
{t('Expires in')}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
'mt-1 text-sm font-semibold tabular-nums',
|
||
isAvailable ? 'text-success' : 'text-muted-foreground'
|
||
)}
|
||
>
|
||
{expiresIn}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='mt-3 grid grid-cols-1 gap-3 sm:grid-cols-3'>
|
||
<ResetCreditTimeField
|
||
label={t('Granted at')}
|
||
value={formatIsoTimestamp(props.credit.granted_at)}
|
||
/>
|
||
<ResetCreditTimeField
|
||
label={t('Expires at')}
|
||
value={formatIsoTimestamp(props.credit.expires_at)}
|
||
/>
|
||
<ResetCreditTimeField
|
||
label={t('Redeemed at')}
|
||
value={formatIsoTimestamp(props.credit.redeemed_at)}
|
||
emphasis={Boolean(props.credit.redeemed_at)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ResetCreditsPanel(props: {
|
||
payload: CodexResetCreditsPayload | null
|
||
response: CodexResetCreditsResponse | null
|
||
usageAvailableCount: string
|
||
isLoading: boolean
|
||
isResetting: boolean
|
||
errorMessage: string
|
||
resetErrorMessage: string
|
||
resetSuccessMessage: string
|
||
onRefresh: () => void
|
||
onRequestReset: () => void
|
||
}) {
|
||
const { t } = useTranslation()
|
||
const credits = useMemo(
|
||
() => sortResetCredits(props.payload?.credits ?? []),
|
||
[props.payload?.credits]
|
||
)
|
||
const detailAvailableCount = props.payload?.available_count
|
||
const availableCount = Number.isFinite(Number(detailAvailableCount))
|
||
? String(detailAvailableCount)
|
||
: props.usageAvailableCount
|
||
const totalEarnedCount = Number.isFinite(
|
||
Number(props.payload?.total_earned_count)
|
||
)
|
||
? String(props.payload?.total_earned_count)
|
||
: '-'
|
||
const canReset = Number(availableCount) > 0
|
||
|
||
return (
|
||
<div className='flex flex-col gap-3 p-3'>
|
||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
|
||
<InfoField
|
||
label={t('Available reset credits')}
|
||
value={availableCount}
|
||
mono
|
||
copyable={false}
|
||
/>
|
||
<InfoField
|
||
label={t('Total earned')}
|
||
value={totalEarnedCount}
|
||
mono
|
||
copyable={false}
|
||
/>
|
||
<InfoField
|
||
label='HTTP'
|
||
value={String(props.response?.upstream_status ?? '-')}
|
||
mono
|
||
copyable={false}
|
||
/>
|
||
</div>
|
||
|
||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||
<div className='text-muted-foreground text-xs leading-5'>
|
||
{t('Available credits are ordered by soonest expiration.')}
|
||
</div>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={props.onRefresh}
|
||
disabled={props.isLoading}
|
||
>
|
||
<RefreshCw data-icon='inline-start' />
|
||
{t('Refresh details')}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className='bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||
<div className='min-w-0'>
|
||
<div className='text-sm font-semibold'>{t('Reset usage window')}</div>
|
||
<div className='text-muted-foreground mt-1 text-xs leading-5'>
|
||
{t(
|
||
'Use one available reset credit to refresh the current Codex usage windows.'
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type='button'
|
||
variant={canReset ? 'destructive' : 'outline'}
|
||
size='sm'
|
||
onClick={props.onRequestReset}
|
||
disabled={!canReset || props.isLoading || props.isResetting}
|
||
className='shrink-0'
|
||
>
|
||
<RotateCcw data-icon='inline-start' />
|
||
{props.isResetting ? t('Resetting...') : t('Apply reset')}
|
||
</Button>
|
||
</div>
|
||
|
||
{!canReset ? (
|
||
<Alert>
|
||
<AlertTriangle />
|
||
<AlertTitle>{t('No reset credits available')}</AlertTitle>
|
||
<AlertDescription>
|
||
{t('The reset request stays disabled until a credit is available.')}
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{props.resetSuccessMessage ? (
|
||
<Alert className='border-success/40 bg-success/10 text-success'>
|
||
<Check />
|
||
<AlertTitle>{t('Reset completed')}</AlertTitle>
|
||
<AlertDescription>{props.resetSuccessMessage}</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{props.resetErrorMessage ? (
|
||
<Alert variant='destructive'>
|
||
<AlertTriangle />
|
||
<AlertTitle>{t('Reset failed')}</AlertTitle>
|
||
<AlertDescription>{props.resetErrorMessage}</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{props.errorMessage ? (
|
||
<div className='border-destructive/40 bg-destructive/10 text-destructive rounded-lg border px-3 py-2 text-sm'>
|
||
{props.errorMessage}
|
||
</div>
|
||
) : props.isLoading ? (
|
||
<div className='flex flex-col gap-2'>
|
||
<Skeleton className='h-24 w-full' />
|
||
<Skeleton className='h-24 w-full' />
|
||
</div>
|
||
) : credits.length > 0 ? (
|
||
<div className='flex flex-col gap-2'>
|
||
{credits.map((credit, index) => (
|
||
<ResetCreditItem
|
||
key={`${credit.id ?? credit.expires_at ?? 'credit'}-${index}`}
|
||
credit={credit}
|
||
index={index}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<Empty className='min-h-32 border'>
|
||
<EmptyHeader>
|
||
<EmptyTitle>{t('No reset credits')}</EmptyTitle>
|
||
<EmptyDescription>
|
||
{t('Upstream did not return reset credit details.')}
|
||
</EmptyDescription>
|
||
</EmptyHeader>
|
||
</Empty>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function CodexUsageDialog({
|
||
open,
|
||
onOpenChange,
|
||
channelName,
|
||
channelId,
|
||
response,
|
||
onRefresh,
|
||
isRefreshing,
|
||
}: CodexUsageDialogProps) {
|
||
const { t } = useTranslation()
|
||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||
const [showRawJson, setShowRawJson] = useState(false)
|
||
const [showResetCredits, setShowResetCredits] = useState(false)
|
||
const [resetCreditsResponse, setResetCreditsResponse] =
|
||
useState<CodexResetCreditsResponse | null>(null)
|
||
const [isLoadingResetCredits, setIsLoadingResetCredits] = useState(false)
|
||
const [resetCreditsError, setResetCreditsError] = useState('')
|
||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false)
|
||
const [isResetting, setIsResetting] = useState(false)
|
||
const [resetActionError, setResetActionError] = useState('')
|
||
const [resetActionMessage, setResetActionMessage] = useState('')
|
||
|
||
const payload: CodexUsagePayload | null = useMemo(() => {
|
||
const raw = response?.data
|
||
if (!raw || typeof raw !== 'object') return null
|
||
return raw as CodexUsagePayload
|
||
}, [response?.data])
|
||
|
||
const resetCreditsPayload: CodexResetCreditsPayload | null = useMemo(() => {
|
||
const raw = resetCreditsResponse?.data
|
||
if (!raw || typeof raw !== 'object') return null
|
||
return raw as CodexResetCreditsPayload
|
||
}, [resetCreditsResponse?.data])
|
||
|
||
const rateLimit = payload?.rate_limit
|
||
const accountType = payload?.plan_type ?? rateLimit?.plan_type
|
||
const accountBadge = getAccountTypeBadge(accountType, t)
|
||
const additionalRateLimits = (payload?.additional_rate_limits ?? []).filter(
|
||
(item) => item && Object.keys(item).length > 0
|
||
)
|
||
const resetCredits =
|
||
resetCreditsPayload?.available_count ??
|
||
payload?.rate_limit_reset_credits?.available_count
|
||
const resetCreditsText = Number.isFinite(Number(resetCredits))
|
||
? String(resetCredits)
|
||
: '-'
|
||
const canResetCodexUsage = Number(resetCredits) > 0
|
||
const channelLabel = `${channelName || '-'}${
|
||
channelId ? ` (#${channelId})` : ''
|
||
}`
|
||
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(payload)
|
||
|
||
const errorMessage =
|
||
response?.success === false
|
||
? response?.message?.trim() || t('Failed to fetch usage')
|
||
: ''
|
||
|
||
const loadResetCredits = useCallback(
|
||
async (force = false) => {
|
||
if (!channelId) {
|
||
setResetCreditsError(t('Channel ID is required'))
|
||
return
|
||
}
|
||
if (isLoadingResetCredits || (!force && resetCreditsResponse)) return
|
||
|
||
setIsLoadingResetCredits(true)
|
||
setResetCreditsError('')
|
||
try {
|
||
const res = await getCodexResetCredits(channelId)
|
||
if (!res.success) {
|
||
throw new Error(
|
||
res.message || t('Failed to fetch reset credit details')
|
||
)
|
||
}
|
||
setResetCreditsResponse(res)
|
||
} catch (error) {
|
||
setResetCreditsError(
|
||
error instanceof Error
|
||
? error.message
|
||
: t('Failed to fetch reset credit details')
|
||
)
|
||
} finally {
|
||
setIsLoadingResetCredits(false)
|
||
}
|
||
},
|
||
[channelId, isLoadingResetCredits, resetCreditsResponse, t]
|
||
)
|
||
|
||
const handleResetCreditsOpenChange = (nextOpen: boolean) => {
|
||
setShowResetCredits(nextOpen)
|
||
if (nextOpen) {
|
||
void loadResetCredits(false)
|
||
}
|
||
}
|
||
|
||
const handleDialogOpenChange = (nextOpen: boolean) => {
|
||
if (!nextOpen) {
|
||
setShowRawJson(false)
|
||
setShowResetCredits(false)
|
||
setResetCreditsResponse(null)
|
||
setResetCreditsError('')
|
||
setIsLoadingResetCredits(false)
|
||
setResetConfirmOpen(false)
|
||
setIsResetting(false)
|
||
setResetActionError('')
|
||
setResetActionMessage('')
|
||
}
|
||
onOpenChange(nextOpen)
|
||
}
|
||
|
||
const handleConfirmReset = async () => {
|
||
if (!channelId || isResetting || !canResetCodexUsage) return
|
||
|
||
setIsResetting(true)
|
||
setResetActionError('')
|
||
setResetActionMessage('')
|
||
try {
|
||
const res = await resetCodexUsage(channelId)
|
||
if (!res.success) {
|
||
throw new Error(res.message || t('Failed to reset usage'))
|
||
}
|
||
|
||
const resetPayload = res.data as
|
||
| { windows_reset?: number; code?: string }
|
||
| undefined
|
||
const windowsReset = Number(resetPayload?.windows_reset)
|
||
setResetActionMessage(
|
||
Number.isFinite(windowsReset)
|
||
? `${t('Reset completed. Latest usage has been refreshed.')} ${t(
|
||
'Affected windows:'
|
||
)} ${windowsReset}`
|
||
: t('Reset completed. Latest usage has been refreshed.')
|
||
)
|
||
setResetConfirmOpen(false)
|
||
await Promise.resolve(onRefresh?.())
|
||
await loadResetCredits(true)
|
||
} catch (error) {
|
||
setResetActionError(
|
||
error instanceof Error ? error.message : t('Failed to reset usage')
|
||
)
|
||
} finally {
|
||
setIsResetting(false)
|
||
}
|
||
}
|
||
|
||
const rawJsonText = useMemo(() => {
|
||
if (!response) return ''
|
||
try {
|
||
return JSON.stringify(
|
||
{
|
||
success: response.success,
|
||
message: response.message,
|
||
upstream_status: response.upstream_status,
|
||
data: response.data,
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
} catch {
|
||
return String(response?.data ?? '')
|
||
}
|
||
}, [response])
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={handleDialogOpenChange}
|
||
title={t('Codex Account & Usage')}
|
||
contentClassName='sm:max-w-[900px]'
|
||
titleClassName='flex items-center gap-2'
|
||
contentHeight='auto'
|
||
bodyClassName='flex flex-col gap-4'
|
||
footer={
|
||
<>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
onClick={() => handleDialogOpenChange(false)}
|
||
>
|
||
{t('Close')}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className='flex flex-col gap-4'>
|
||
{errorMessage && (
|
||
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
|
||
{errorMessage}
|
||
</div>
|
||
)}
|
||
|
||
<Card size='sm' className='bg-muted/30 gap-0 py-0'>
|
||
<CardHeader className='p-4 pb-2'>
|
||
<CardTitle className='text-muted-foreground text-xs font-medium'>
|
||
{t('Codex Account Status')}
|
||
</CardTitle>
|
||
{onRefresh ? (
|
||
<CardAction>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={onRefresh}
|
||
disabled={Boolean(isRefreshing)}
|
||
>
|
||
<RefreshCw data-icon='inline-start' />
|
||
{t('Refresh')}
|
||
</Button>
|
||
</CardAction>
|
||
) : null}
|
||
</CardHeader>
|
||
<CardContent className='p-4 pt-0'>
|
||
<div className='flex flex-wrap items-center gap-2'>
|
||
<StatusBadge
|
||
label={accountBadge.label}
|
||
variant={accountBadge.variant}
|
||
copyable={false}
|
||
/>
|
||
{getUsageStatusBadge(rateLimit, t)}
|
||
<StatusBadge
|
||
label={`HTTP ${response?.upstream_status ?? '-'}`}
|
||
variant='neutral'
|
||
copyable={false}
|
||
/>
|
||
<StatusBadge
|
||
label={formatLabelValue(t('Reset count:'), resetCreditsText)}
|
||
variant={Number(resetCredits) > 0 ? 'blue' : 'neutral'}
|
||
copyable={false}
|
||
/>
|
||
{payload?.credits?.overage_limit_reached ? (
|
||
<StatusBadge
|
||
label={t('Overage limited')}
|
||
variant='danger'
|
||
copyable={false}
|
||
/>
|
||
) : null}
|
||
{payload?.spend_control?.reached ? (
|
||
<StatusBadge
|
||
label={t('Spend limited')}
|
||
variant='danger'
|
||
copyable={false}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div className='mt-4 grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||
<InfoField
|
||
label={t('Email')}
|
||
value={payload?.email}
|
||
copyable={true}
|
||
/>
|
||
<InfoField
|
||
label={t('Channel')}
|
||
value={channelLabel}
|
||
copyable={false}
|
||
/>
|
||
<InfoField
|
||
label='User ID'
|
||
value={payload?.user_id}
|
||
mono
|
||
className='md:col-span-2'
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Collapsible
|
||
open={showResetCredits}
|
||
onOpenChange={handleResetCreditsOpenChange}
|
||
className='rounded-lg border'
|
||
>
|
||
<CollapsibleTrigger
|
||
render={
|
||
<button
|
||
type='button'
|
||
className='hover:bg-muted/40 flex w-full items-start justify-between gap-3 p-3 text-left transition-colors'
|
||
aria-expanded={showResetCredits}
|
||
/>
|
||
}
|
||
>
|
||
<div className='min-w-0'>
|
||
<div className='flex flex-wrap items-center gap-2'>
|
||
<div className='text-sm font-semibold'>
|
||
{t('Reset Credits')}
|
||
</div>
|
||
<StatusBadge
|
||
label={`${t('Available')} ${resetCreditsText}`}
|
||
variant={Number(resetCredits) > 0 ? 'blue' : 'neutral'}
|
||
copyable={false}
|
||
/>
|
||
</div>
|
||
<div className='text-muted-foreground mt-1 text-xs leading-5'>
|
||
{t('View issued reset credits, grant dates, and expiration.')}
|
||
</div>
|
||
</div>
|
||
{showResetCredits ? (
|
||
<ChevronUp className='text-muted-foreground mt-0.5 shrink-0' />
|
||
) : (
|
||
<ChevronDown className='text-muted-foreground mt-0.5 shrink-0' />
|
||
)}
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className='border-t'>
|
||
<ResetCreditsPanel
|
||
payload={resetCreditsPayload}
|
||
response={resetCreditsResponse}
|
||
usageAvailableCount={resetCreditsText}
|
||
isLoading={isLoadingResetCredits}
|
||
isResetting={isResetting}
|
||
errorMessage={resetCreditsError}
|
||
resetErrorMessage={resetActionError}
|
||
resetSuccessMessage={resetActionMessage}
|
||
onRefresh={() => void loadResetCredits(true)}
|
||
onRequestReset={() => {
|
||
setResetActionError('')
|
||
setResetActionMessage('')
|
||
setResetConfirmOpen(true)
|
||
}}
|
||
/>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
<div className='flex flex-col gap-3'>
|
||
<SectionHeading
|
||
title={t('Base Limits')}
|
||
description={t('Base rate limit windows for this account.')}
|
||
>
|
||
{getUsageStatusBadge(rateLimit, t)}
|
||
</SectionHeading>
|
||
<RateLimitWindowGrid
|
||
fiveHourWindow={fiveHourWindow}
|
||
weeklyWindow={weeklyWindow}
|
||
/>
|
||
</div>
|
||
|
||
{additionalRateLimits.length > 0 ? (
|
||
<div className='flex flex-col gap-3'>
|
||
<SectionHeading
|
||
title={t('Additional Limits')}
|
||
description={t(
|
||
'Per-feature metered windows split by model or capability.'
|
||
)}
|
||
/>
|
||
<div className='flex flex-col gap-3'>
|
||
{additionalRateLimits.map((item, index) => {
|
||
const limitName =
|
||
item.limit_name ||
|
||
item.metered_feature ||
|
||
`${t('Additional Limit')} ${index + 1}`
|
||
return (
|
||
<RateLimitGroupSection
|
||
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
|
||
title={limitName}
|
||
description={t('Additional metered capability')}
|
||
source={item}
|
||
meteredFeature={item.metered_feature}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Collapsible
|
||
open={showRawJson}
|
||
onOpenChange={setShowRawJson}
|
||
className='rounded-lg border'
|
||
>
|
||
<CollapsibleTrigger
|
||
render={
|
||
<button
|
||
type='button'
|
||
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
|
||
aria-expanded={showRawJson}
|
||
/>
|
||
}
|
||
>
|
||
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
|
||
{showRawJson ? (
|
||
<ChevronUp className='text-muted-foreground h-4 w-4' />
|
||
) : (
|
||
<ChevronDown className='text-muted-foreground h-4 w-4' />
|
||
)}
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<>
|
||
<div className='flex justify-end border-t px-3 py-2'>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={() => copyToClipboard(rawJsonText)}
|
||
disabled={!rawJsonText}
|
||
>
|
||
{copiedText === rawJsonText ? (
|
||
<Check data-icon='inline-start' className='text-success' />
|
||
) : (
|
||
<Copy data-icon='inline-start' />
|
||
)}
|
||
{t('Copy')}
|
||
</Button>
|
||
</div>
|
||
<ScrollArea className='max-h-[50vh]'>
|
||
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
|
||
{rawJsonText || '-'}
|
||
</pre>
|
||
</ScrollArea>
|
||
</>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
<ConfirmDialog
|
||
open={resetConfirmOpen}
|
||
onOpenChange={setResetConfirmOpen}
|
||
title={t('Confirm usage reset')}
|
||
desc={
|
||
<div className='flex flex-col gap-2'>
|
||
<p>
|
||
{t(
|
||
'Use one available reset credit for this channel. The reset request is sent only after confirmation.'
|
||
)}
|
||
</p>
|
||
<div className='bg-muted/50 rounded-lg border px-3 py-2 text-xs'>
|
||
<div className='font-medium'>{channelLabel}</div>
|
||
<div className='text-muted-foreground mt-1'>
|
||
{t('Available reset credits')}: {resetCreditsText}
|
||
</div>
|
||
</div>
|
||
<p className='text-destructive'>
|
||
{t('Used reset credits cannot be restored.')}
|
||
</p>
|
||
</div>
|
||
}
|
||
destructive
|
||
disabled={!canResetCodexUsage}
|
||
isLoading={isResetting}
|
||
cancelBtnText={t('Cancel')}
|
||
confirmText={isResetting ? t('Resetting...') : t('Apply reset')}
|
||
handleConfirm={handleConfirmReset}
|
||
/>
|
||
</Dialog>
|
||
)
|
||
}
|