feat(logs): add username to TaskLog interface and implement log avatar styling

This commit is contained in:
CaIon
2026-04-29 09:52:45 +08:00
parent db48108d21
commit 75af3db11f
19 changed files with 899 additions and 561 deletions
@@ -110,7 +110,7 @@ export function ApiKeyGroupCombobox({
role='combobox'
aria-expanded={open}
disabled={disabled}
className='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start'
className='border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px]'
>
<span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
<span className='min-w-0'>
@@ -128,7 +128,7 @@ export function ApiKeyGroupCombobox({
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'>
<PopoverContent className='data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl p-0 shadow-lg data-[state=closed]:duration-75 data-[state=open]:duration-100'>
<Command shouldFilter={false}>
<CommandInput
placeholder={t('Search...')}
@@ -143,7 +143,7 @@ export function ApiKeyGroupCombobox({
key={option.value}
value={option.value}
onSelect={handleSelect}
className='items-start gap-3 px-3 py-3'
className='items-start gap-3 rounded-lg px-3 py-3 transition-colors data-[selected=true]:bg-muted'
>
<Check
className={cn(
@@ -31,6 +31,16 @@ function getQuotaProgressColor(percentage: number): string {
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
function getGroupRatioClassName(ratio: number): string {
if (ratio > 1) {
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300'
}
if (ratio < 1) {
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300'
}
return 'border-border bg-muted text-muted-foreground'
}
function useGroupRatios(): Record<string, number> {
const isAdmin = useAuthStore((s) =>
Boolean(s.auth.user?.role && s.auth.user.role >= 10)
@@ -242,15 +252,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
}
return (
<span className='inline-flex items-center gap-1.5 text-xs'>
<span className='inline-flex items-center gap-2 text-xs'>
<span className='font-medium'>{group || t('Default')}</span>
{ratio != null && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/60 font-mono tabular-nums'>
{ratio}x
</span>
</>
<span
className={cn(
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
getGroupRatioClassName(ratio)
)}
>
<span className='size-1 rounded-full bg-current opacity-60' />
<span>{ratio}x</span>
</span>
)}
</span>
)
@@ -1,12 +1,19 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown } from 'lucide-react'
import {
ChevronDown,
KeyRound,
Settings2,
WalletCards,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getUserModels, getUserGroups } from '@/lib/api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Collapsible,
@@ -59,6 +66,34 @@ type ApiKeyMutateDrawerProps = {
side?: 'left' | 'right'
}
type ApiKeyFormSectionProps = {
title: string
description: string
icon: LucideIcon
children: ReactNode
}
function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
const Icon = props.icon
return (
<section className='bg-card rounded-lg border'>
<div className='flex items-center gap-3 border-b px-4 py-3'>
<div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
<Icon className='size-5' />
</div>
<div className='min-w-0'>
<h3 className='text-sm font-medium leading-none'>{props.title}</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{props.description}
</p>
</div>
</div>
<div className='space-y-4 p-4'>{props.children}</div>
</section>
)
}
export function ApiKeysMutateDrawer({
open,
onOpenChange,
@@ -201,6 +236,8 @@ export function ApiKeysMutateDrawer({
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel })
const selectedGroup = form.watch('group')
const unlimitedQuota = form.watch('unlimited_quota')
return (
<Sheet
@@ -214,10 +251,10 @@ export function ApiKeysMutateDrawer({
>
<SheetContent
side={side}
className='flex w-full flex-col sm:max-w-[600px]'
className='bg-background flex w-full gap-0 overflow-hidden p-0 sm:max-w-[620px]'
>
<SheetHeader className='text-start'>
<SheetTitle>
<SheetHeader className='bg-background border-b px-5 py-4 text-start'>
<SheetTitle className='text-lg'>
{isUpdate ? t('Update API Key') : t('Create API Key')}
</SheetTitle>
<SheetDescription>
@@ -231,278 +268,314 @@ export function ApiKeysMutateDrawer({
<form
id='api-key-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-6 overflow-y-auto px-4'
className='flex-1 space-y-4 overflow-y-auto px-4 py-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<FormControl>
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select a group')}
/>
</FormControl>
<FormDescription>
{t('Auto group enables circuit breaker mechanism')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('group') === 'auto' && (
<ApiKeyFormSection
title={t('Basic Information')}
description={t('Set API key basic information')}
icon={KeyRound}
>
<FormField
control={form.control}
name='cross_group_retry'
name='name'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Cross-group retry')}
</FormLabel>
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('Enter a name')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<FormControl>
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select a group')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{selectedGroup === 'auto' && (
<FormField
control={form.control}
name='cross_group_retry'
render={({ field }) => (
<FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Cross-group retry')}
</FormLabel>
<FormDescription className='text-xs'>
{t(
'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
className='min-w-0'
/>
</FormControl>
<div className='grid grid-cols-4 gap-2 sm:flex'>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1 Month')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(0, 0, 1)}
>
{t('1 Hour')}
</Button>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='tokenCount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
placeholder={t('Number of keys to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
</FormControl>
<FormDescription>
{t(
'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
'Create multiple API keys at once (random suffix will be added to names)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</ApiKeyFormSection>
<ApiKeyFormSection
title={t('Quota Settings')}
description={t('Set quota amount and limits')}
icon={WalletCards}
>
{!unlimitedQuota && (
<FormField
control={form.control}
name='remain_quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='unlimited_quota'
render={({ field }) => (
<FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Unlimited Quota')}
</FormLabel>
<FormDescription className='text-xs'>
{t('Enable unlimited quota for this API key')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={!!field.value}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='unlimited_quota'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Unlimited Quota')}
</FormLabel>
<FormDescription>
{t('Enable unlimited quota for this API key')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{!form.watch('unlimited_quota') && (
<FormField
control={form.control}
name='remain_quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='space-y-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1 Month')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 1)}
>
{t('1 Hour')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='tokenCount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
placeholder={t('Number of keys to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
</FormControl>
<FormDescription>
{t(
'Create multiple API keys at once (random suffix will be added to names)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</ApiKeyFormSection>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
type='button'
variant='outline'
className='flex w-full items-center justify-between'
>
<span className='font-medium'>{t('Advanced Options')}</span>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
advancedOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='space-y-6 pt-6'>
<FormField
control={form.control}
name='model_limits'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model Limits')}</FormLabel>
<FormControl>
<MultiSelect
options={models.map((m) => ({ label: m, value: m }))}
selected={field.value}
onChange={field.onChange}
placeholder={t('Select models (empty for allow all)')}
/>
</FormControl>
<FormDescription>
{t('Limit which models can be used with this key')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<section className='bg-card rounded-lg border'>
<CollapsibleTrigger asChild>
<button
type='button'
className='hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors'
>
<div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
<Settings2 className='size-5' />
</div>
<div className='min-w-0 flex-1'>
<h3 className='text-sm font-medium leading-none'>
{t('Advanced Settings')}
</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Set API key access restrictions')}
</p>
</div>
<ChevronDown
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform',
advancedOpen && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='space-y-4 border-t p-4'>
<FormField
control={form.control}
name='model_limits'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model Limits')}</FormLabel>
<FormControl>
<MultiSelect
options={models.map((m) => ({
label: m,
value: m,
}))}
selected={field.value}
onChange={field.onChange}
placeholder={t(
'Select models (empty for allow all)'
)}
/>
</FormControl>
<FormDescription>
{t('Limit which models can be used with this key')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_ips'
render={({ field }) => (
<FormItem>
<FormLabel>{t('IP Whitelist (supports CIDR)')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t(
'One IP per line (empty for no restriction)'
)}
rows={3}
/>
</FormControl>
<FormDescription>
{t(
'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
<FormField
control={form.control}
name='allow_ips'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('IP Whitelist (supports CIDR)')}
</FormLabel>
<FormControl>
<Textarea
{...field}
className='min-h-20 resize-none'
placeholder={t(
'One IP per line (empty for no restriction)'
)}
rows={3}
/>
</FormControl>
<FormDescription>
{t(
'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</section>
</Collapsible>
</form>
</Form>
<SheetFooter className='gap-2'>
<SheetFooter className='bg-background gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end'>
<SheetClose asChild>
<Button variant='outline'>{t('Close')}</Button>
</SheetClose>