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:
@@ -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
-22
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+62
-65
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+121
-130
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+255
-306
@@ -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
|
||||
|
||||
+27
-30
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+110
-122
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+212
-214
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+200
-212
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+102
-98
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+155
-163
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+166
-171
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user