perf(web): improve dialog sizing and footer layout

- 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.
This commit is contained in:
QuentinHsu
2026-06-06 21:49:33 +08:00
parent 7a5348caa3
commit 2eaa943d9f
80 changed files with 8137 additions and 8517 deletions
@@ -24,20 +24,13 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchEnableModels,
handleBatchDisableModels,
@@ -187,19 +180,17 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Models?')}</DialogTitle>
<DialogDescription>
{t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Models?')}
description={t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -209,8 +200,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -17,14 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
type DescriptionDialogProps = {
open: boolean
@@ -41,21 +35,22 @@ export function DescriptionDialog({
}: DescriptionDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{modelName}</DialogTitle>
<DialogDescription>{t('Model Description')}</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
</DialogContent>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={modelName}
description={t('Model Description')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -22,15 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -164,62 +158,16 @@ export function ExtendDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Extend deployment')}</DialogTitle>
</DialogHeader>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
<DialogFooter className='mt-4'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Extend deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -229,8 +177,57 @@ export function ExtendDeploymentDialog({
) : null}
{t('Extend')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -37,6 +30,7 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { getMissingModels } from '../../api'
import { DEFAULT_PAGE_SIZE } from '../../constants'
@@ -115,133 +109,130 @@ export function MissingModelsDialog({
const showPagination = totalItems > pageSize
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Missing Models')}</DialogTitle>
<DialogDescription>
{t('Models that are being used but not configured in the system')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Missing Models')}
description={t(
'Models that are being used but not configured in the system'
)}
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4'
initialFocus={!isMobile}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')} {totalItems}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
</div>
</div>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{totalItems}
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) =>
Math.min(totalPages, prev + 1)
)
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
</DialogContent>
)}
</div>
)}
</Dialog>
)
}
@@ -25,7 +25,6 @@ import {
Plus,
RefreshCcw,
Trash2,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -40,14 +39,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -64,6 +55,7 @@ import {
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'
@@ -172,186 +164,233 @@ export function PrefillGroupManagementDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className='prefill-dialog-content !top-4 !flex !-translate-y-0 !flex-col !gap-0 !border-none !bg-transparent !p-0 !shadow-none sm:!top-1/2 sm:!-translate-y-1/2'
style={{ maxWidth: 'min(100vw, 64rem)' }}
>
<div
className={cn(
'prefill-dialog-panel border-border/70 bg-background flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden border shadow-2xl',
isMobile ? 'rounded-none' : 'rounded-2xl'
)}
>
<div
className={cn(
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5',
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]'
)}
<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}
>
<DialogHeader className='max-w-3xl gap-3 pr-12 text-start sm:pr-0'>
<DialogTitle className='flex flex-wrap items-center gap-2 text-xl'>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</DialogTitle>
<DialogDescription className='text-base leading-relaxed sm:text-sm'>
{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 reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
)}
</DialogDescription>
</DialogHeader>
<DialogClose
render={
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 border border-transparent sm:top-5 sm:right-6'
/>
}
>
<span className='sr-only'>{t('Close dialog')}</span>
<X className='h-4 w-4' />
</DialogClose>
</div>
<div className='flex flex-wrap items-center gap-3 border-b px-4 py-3 text-sm sm:px-6'>
<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={cn(
'flex flex-1 flex-col overflow-hidden px-4 py-4 sm:px-6 sm:py-6',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1.5rem)]'
)}
>
<div className='flex-1 overflow-y-auto'>
<div className='flex flex-col gap-4'>
{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-16 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>
</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>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed'>
<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-4'>
{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 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 ? (
<div className='flex flex-wrap gap-2'>
<>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
@@ -368,7 +407,7 @@ export function PrefillGroupManagementDialog({
copyable={false}
/>
)}
</div>
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
@@ -376,131 +415,41 @@ export function PrefillGroupManagementDialog({
: 'No items configured yet.'}
</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[280px]'>
{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>
</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>
</DialogContent>
)}
</div>
</Dialog>
<ConfirmDialog
@@ -22,14 +22,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -111,27 +105,16 @@ export function RenameDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Rename deployment')}</DialogTitle>
</DialogHeader>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
<DialogFooter className='mt-4'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Rename deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -141,8 +124,22 @@ export function RenameDeploymentDialog({
) : null}
{t('Rename')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
</Dialog>
)
}
@@ -24,16 +24,9 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { syncUpstream, previewUpstreamDiff } from '../../api'
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
@@ -125,117 +118,16 @@ export function SyncWizardDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
<DialogDescription>
{t('Synchronize models and vendors from an upstream source')}
</DialogDescription>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</div>
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Sync Upstream Models')}
description={t('Synchronize models and vendors from an upstream source')}
initialFocus={!isMobile}
contentHeight='auto'
bodyClassName='flex flex-col gap-6'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -246,10 +138,106 @@ export function SyncWizardDialog({
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
<RefreshCw className='mr-2 h-4 w-4' />
{isSyncing ? 'Syncing...' : 'Sync Now'}
{isSyncing ? t('Syncing...') : t('Sync Now')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</Dialog>
)
}
@@ -30,13 +30,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -47,6 +40,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { getDeployment, updateDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -64,6 +58,8 @@ const schema = z.object({
type Values = z.input<typeof schema>
const UPDATE_CONFIG_FORM_ID = 'update-config-form'
function normalizeJsonObject(input?: string) {
if (!input || !input.trim()) return undefined
const parsed = JSON.parse(input)
@@ -212,226 +208,228 @@ export function UpdateConfigDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='image_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input
placeholder='ollama/ollama:latest'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Entrypoint (space separated)')}
</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={title}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
isLoading ? null : (
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={UPDATE_CONFIG_FORM_ID}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</>
)
}
>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
id={UPDATE_CONFIG_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='command'
name='image_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Command')}</FormLabel>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input placeholder='Optional' {...field} />
<Input placeholder='ollama/ollama:latest' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Secret env (JSON object)')}
</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Entrypoint (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
)}
</DialogContent>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='command'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Command')}</FormLabel>
<FormControl>
<Input placeholder='Optional' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Secret env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</div>
)}
</Dialog>
)
}
@@ -37,14 +37,6 @@ import { toast } from 'sonner'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Popover,
@@ -67,6 +59,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { applyUpstreamOverwrite } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
@@ -453,222 +446,217 @@ export function UpstreamConflictDialog({
}
onOpenChange(nextOpen)
}}
>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6'
initialFocus={!isMobile}
>
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Resolve Conflicts')}</DialogTitle>
<DialogDescription>
title={t('Resolve Conflicts')}
description={t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
)}
contentClassName='w-full sm:max-w-5xl'
contentHeight='min(72vh, 720px)'
bodyClassName='flex flex-col gap-4'
initialFocus={!isMobile}
footerClassName='sm:justify-between'
footer={
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</DialogDescription>
</DialogHeader>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
<div className='text-muted-foreground text-xs'>
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
}
>
<div className='flex min-h-0 flex-1 flex-col gap-4'>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
<div className='text-muted-foreground text-xs'>
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
</div>
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter className='flex-shrink-0'>
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
)}
</div>
</DialogFooter>
</DialogContent>
)}
</div>
</Dialog>
)
}
@@ -24,14 +24,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { createVendor, updateVendor } from '../../api'
import { vendorsQueryKeys, modelsQueryKeys } from '../../lib'
import { vendorFormSchema, type Vendor } from '../../types'
@@ -53,6 +46,8 @@ type VendorMutateDialogProps = {
currentVendor?: Vendor | null
}
const VENDOR_MUTATE_FORM_ID = 'vendor-mutate-form'
export function VendorMutateDialog({
open,
onOpenChange,
@@ -118,98 +113,107 @@ export function VendorMutateDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEdit ? t('Edit Vendor') : t('Create Vendor')}
</DialogTitle>
<DialogDescription>
{isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t('Edit Vendor') : t('Create Vendor')}
description={
isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={VENDOR_MUTATE_FORM_ID}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : isEdit ? t('Update') : t('Create')}
</Button>
</>
}
>
<Form {...form}>
<form
id={VENDOR_MUTATE_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -27,14 +27,8 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { getDeployment, listDeploymentContainers } from '../../api'
export function ViewDetailsDialog({
@@ -116,160 +110,15 @@ export function ViewDetailsDialog({
}, [details])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Deployment details')}</DialogTitle>
</DialogHeader>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(
details?.total_gpus ?? details?.hardware_qty ?? '-'
)}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{id}
</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Deployment details')}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -277,8 +126,151 @@ export function ViewDetailsDialog({
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(details?.total_gpus ?? details?.hardware_qty ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>{id}</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
</Dialog>
)
}
@@ -21,12 +21,6 @@ import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -36,6 +30,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Dialog } from '@/components/dialog'
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
interface ViewLogsDialogProps {
@@ -142,180 +137,180 @@ export function ViewLogsDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</DialogTitle>
</DialogHeader>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</>
}
contentClassName='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div>
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>
{t('Container')}
</div>
<Select
items={[
...containers.flatMap((c) => {
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Container')}</div>
<Select
items={[
...containers.flatMap((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return []
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return []
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</DialogContent>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
</div>
</Dialog>
)
}