2eaa943d9f
- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content. - tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows. - update channel terminology and dialog action translations across supported locales.
474 lines
17 KiB
TypeScript
Vendored
474 lines
17 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 { useEffect, useMemo, useState } from 'react'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
Layers3,
|
|
Loader2,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCcw,
|
|
Trash2,
|
|
} from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { cn } from '@/lib/utils'
|
|
import { useIsMobile } from '@/hooks/use-mobile'
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import {
|
|
Empty,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from '@/components/ui/empty'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import { Dialog } from '@/components/dialog'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { TableId } from '@/components/table-id'
|
|
import { deletePrefillGroup, getPrefillGroups } from '../../api'
|
|
import { prefillGroupsQueryKeys } from '../../lib'
|
|
import type { PrefillGroup } from '../../types'
|
|
import {
|
|
PREFILL_GROUP_TYPE_META,
|
|
parseEndpointKeys,
|
|
parseStringItems,
|
|
} from '../prefill-group-shared'
|
|
|
|
type PrefillGroupManagementDialogProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onCreateGroup: () => void
|
|
onEditGroup: (group: PrefillGroup) => void
|
|
}
|
|
|
|
export function PrefillGroupManagementDialog({
|
|
open,
|
|
onOpenChange,
|
|
onCreateGroup,
|
|
onEditGroup,
|
|
}: PrefillGroupManagementDialogProps) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const isMobile = useIsMobile()
|
|
const [deleteState, setDeleteState] = useState<{
|
|
open: boolean
|
|
group: PrefillGroup | null
|
|
}>({ open: false, group: null })
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isFetching,
|
|
error,
|
|
refetch: refetchGroups,
|
|
} = useQuery({
|
|
queryKey: prefillGroupsQueryKeys.list(),
|
|
queryFn: () => getPrefillGroups(),
|
|
enabled: open,
|
|
})
|
|
|
|
const groups = useMemo(() => data?.data ?? [], [data?.data])
|
|
|
|
const sortedGroups = useMemo(
|
|
() =>
|
|
[...groups].sort((a, b) => {
|
|
if (a.type === b.type) {
|
|
return a.name.localeCompare(b.name)
|
|
}
|
|
return a.type.localeCompare(b.type)
|
|
}),
|
|
[groups]
|
|
)
|
|
|
|
const normalizedGroups = useMemo(
|
|
() =>
|
|
sortedGroups.map((group) => {
|
|
const meta = PREFILL_GROUP_TYPE_META[group.type] || {
|
|
label: group.type,
|
|
badge: 'neutral' as const,
|
|
}
|
|
const parsedItems =
|
|
group.type === 'endpoint'
|
|
? parseEndpointKeys(group.items)
|
|
: parseStringItems(group.items)
|
|
return { group, meta, parsedItems }
|
|
}),
|
|
[sortedGroups]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setDeleteState({ open: false, group: null })
|
|
setIsDeleting(false)
|
|
}
|
|
}, [open])
|
|
|
|
const handleDeleteClick = (group: PrefillGroup) => {
|
|
setDeleteState({ open: true, group })
|
|
}
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!deleteState.group) return
|
|
setIsDeleting(true)
|
|
try {
|
|
const response = await deletePrefillGroup(deleteState.group.id)
|
|
if (response.success) {
|
|
toast.success(`Deleted "${deleteState.group.name}"`)
|
|
queryClient.invalidateQueries({
|
|
queryKey: prefillGroupsQueryKeys.lists(),
|
|
})
|
|
setDeleteState({ open: false, group: null })
|
|
} else {
|
|
toast.error(response.message || 'Failed to delete group')
|
|
}
|
|
} catch (err: unknown) {
|
|
toast.error((err as Error)?.message || 'Failed to delete group')
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={
|
|
<>
|
|
<Layers3 className='text-foreground/80 h-5 w-5' />
|
|
{t('Prefill Group Management')}
|
|
</>
|
|
}
|
|
description={t(
|
|
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
|
|
)}
|
|
contentClassName={cn(
|
|
'w-[calc(100vw-2rem)] sm:max-w-[52rem]',
|
|
isMobile && 'max-w-none rounded-none'
|
|
)}
|
|
titleClassName='flex flex-wrap items-center gap-2 text-lg'
|
|
descriptionClassName='text-sm leading-relaxed'
|
|
contentHeight='auto'
|
|
bodyClassName={cn(
|
|
'space-y-3',
|
|
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]'
|
|
)}
|
|
>
|
|
<div className='bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-md border p-2 text-sm'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<Button size='sm' onClick={onCreateGroup}>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
{t('New Group')}
|
|
</Button>
|
|
<Button
|
|
size='sm'
|
|
variant='ghost'
|
|
onClick={() => refetchGroups()}
|
|
disabled={isFetching}
|
|
>
|
|
{isFetching ? (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
) : (
|
|
<RefreshCcw className='mr-2 h-4 w-4' />
|
|
)}
|
|
{t('Refresh')}
|
|
</Button>
|
|
</div>
|
|
<StatusBadge
|
|
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
|
|
variant='neutral'
|
|
copyable={false}
|
|
/>
|
|
</div>
|
|
|
|
<div className='flex flex-col gap-3'>
|
|
{error && (
|
|
<Alert variant='destructive'>
|
|
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
|
|
<AlertDescription>
|
|
{(error as Error).message ||
|
|
'Please retry or refresh the page.'}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<div className='flex flex-col items-center justify-center gap-2 py-12 text-center'>
|
|
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
|
|
<p className='text-muted-foreground text-sm'>
|
|
{t('Fetching prefill groups...')}
|
|
</p>
|
|
</div>
|
|
) : normalizedGroups.length === 0 ? (
|
|
<Empty className='border border-dashed py-10'>
|
|
<EmptyMedia variant='icon'>
|
|
<Layers3 className='h-6 w-6' />
|
|
</EmptyMedia>
|
|
<EmptyHeader>
|
|
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
|
|
<EmptyDescription>
|
|
{t(
|
|
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
|
|
)}
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
<EmptyDescription>
|
|
{t(
|
|
'Prefill groups help you keep complex configurations in sync.'
|
|
)}
|
|
</EmptyDescription>
|
|
</Empty>
|
|
) : isMobile ? (
|
|
<div className='space-y-3'>
|
|
{normalizedGroups.map(({ group, meta, parsedItems }) => (
|
|
<Card key={group.id} className='border-border/60'>
|
|
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
|
<div className='space-y-2'>
|
|
<CardTitle className='flex flex-wrap items-center gap-2'>
|
|
{group.name}
|
|
<StatusBadge
|
|
variant={meta.badge}
|
|
size='sm'
|
|
copyable={false}
|
|
>
|
|
{meta.label}
|
|
<span className='text-muted-foreground/30'>·</span>
|
|
<span className='text-muted-foreground font-mono'>
|
|
#{group.id}
|
|
</span>
|
|
</StatusBadge>
|
|
</CardTitle>
|
|
{group.description ? (
|
|
<CardDescription className='line-clamp-2'>
|
|
{group.description}
|
|
</CardDescription>
|
|
) : (
|
|
<CardDescription className='text-muted-foreground italic'>
|
|
No description provided
|
|
</CardDescription>
|
|
)}
|
|
</div>
|
|
|
|
<div className='flex items-center gap-2'>
|
|
<Button
|
|
size='icon'
|
|
variant='outline'
|
|
onClick={() => onEditGroup(group)}
|
|
>
|
|
<Pencil className='h-4 w-4' />
|
|
<span className='sr-only'>Edit group</span>
|
|
</Button>
|
|
<Button
|
|
size='icon'
|
|
variant='ghost'
|
|
className='text-destructive hover:text-destructive'
|
|
onClick={() => handleDeleteClick(group)}
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
<span className='sr-only'>Delete group</span>
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className='space-y-3'>
|
|
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
|
|
<span>Items</span>
|
|
<StatusBadge
|
|
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
|
|
variant='neutral'
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
</div>
|
|
{parsedItems.length > 0 ? (
|
|
<div className='flex flex-wrap gap-2'>
|
|
{parsedItems.slice(0, 6).map((item) => (
|
|
<StatusBadge
|
|
key={item}
|
|
label={item}
|
|
autoColor={item}
|
|
size='sm'
|
|
/>
|
|
))}
|
|
{parsedItems.length > 6 && (
|
|
<StatusBadge
|
|
label={`+${parsedItems.length - 6} more`}
|
|
variant='neutral'
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className='text-muted-foreground text-sm'>
|
|
{group.type === 'endpoint'
|
|
? 'No endpoint mappings configured.'
|
|
: 'No items configured yet.'}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className='rounded-md border'>
|
|
<div className='w-full overflow-x-auto'>
|
|
<Table className='min-w-[680px]'>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('Group')}</TableHead>
|
|
<TableHead>{t('Type')}</TableHead>
|
|
<TableHead className='min-w-[240px]'>
|
|
{t('Items')}
|
|
</TableHead>
|
|
<TableHead className='w-[120px] text-right'>
|
|
{t('Actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{normalizedGroups.map(({ group, meta, parsedItems }) => (
|
|
<TableRow key={group.id}>
|
|
<TableCell className='align-top whitespace-normal'>
|
|
<div className='flex flex-col gap-1'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<span className='font-medium'>{group.name}</span>
|
|
<TableId value={group.id} />
|
|
</div>
|
|
{group.description ? (
|
|
<p className='text-muted-foreground text-xs'>
|
|
{group.description}
|
|
</p>
|
|
) : (
|
|
<p className='text-muted-foreground text-xs italic'>
|
|
No description provided
|
|
</p>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className='align-top'>
|
|
<StatusBadge
|
|
label={meta.label}
|
|
variant={meta.badge}
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className='align-top whitespace-normal'>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{parsedItems.length > 0 ? (
|
|
<>
|
|
{parsedItems.slice(0, 6).map((item) => (
|
|
<StatusBadge
|
|
key={item}
|
|
label={item}
|
|
autoColor={item}
|
|
size='sm'
|
|
/>
|
|
))}
|
|
{parsedItems.length > 6 && (
|
|
<StatusBadge
|
|
label={`+${parsedItems.length - 6} more`}
|
|
variant='neutral'
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className='text-muted-foreground text-sm'>
|
|
{group.type === 'endpoint'
|
|
? 'No endpoint mappings configured.'
|
|
: 'No items configured yet.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
|
|
{parsedItems.length} item
|
|
{parsedItems.length === 1 ? '' : 's'}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className='align-top'>
|
|
<div className='flex justify-end gap-2'>
|
|
<Button
|
|
size='icon'
|
|
variant='outline'
|
|
onClick={() => onEditGroup(group)}
|
|
>
|
|
<Pencil className='h-4 w-4' />
|
|
<span className='sr-only'>Edit group</span>
|
|
</Button>
|
|
<Button
|
|
size='icon'
|
|
variant='ghost'
|
|
className='text-destructive hover:text-destructive'
|
|
onClick={() => handleDeleteClick(group)}
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
<span className='sr-only'>Delete group</span>
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={deleteState.open}
|
|
onOpenChange={(next) => setDeleteState({ open: next, group: null })}
|
|
title={t('Delete group')}
|
|
desc={
|
|
<p>
|
|
{t('Are you sure you want to delete')}{' '}
|
|
<span className='font-medium'>{deleteState.group?.name}</span>
|
|
{t('? This action cannot be undone.')}
|
|
</p>
|
|
}
|
|
destructive
|
|
confirmText={isDeleting ? 'Deleting...' : 'Delete'}
|
|
isLoading={isDeleting}
|
|
handleConfirm={handleDeleteConfirm}
|
|
/>
|
|
</>
|
|
)
|
|
}
|