dify/web/features/deployments/list/index.tsx
Stephen Zhou 75bfb58cd9
tweaks
2026-05-12 09:15:07 +08:00

191 lines
6.8 KiB
TypeScript

'use client'
import type { ReactNode } from 'react'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { debounce, useQueryState } from 'nuqs'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
import { NewInstanceCard } from './new-instance-card'
import { envFilterQueryState, keywordsQueryState } from './query-state'
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
function DeploymentsListState({ children }: {
children: ReactNode
}) {
return (
<div className="col-span-full rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
function InstanceCardSkeleton() {
return (
<div className="relative col-span-1 inline-flex h-40 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
<div className="relative shrink-0">
<SkeletonRectangle className="my-0 size-10 animate-pulse rounded-lg" />
<SkeletonRectangle className="absolute -right-0.5 -bottom-0.5 my-0 size-4 animate-pulse rounded-sm shadow-xs" />
</div>
<div className="flex w-0 grow flex-col gap-1.5 py-px">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</div>
<div className="flex grow flex-col gap-2 px-3.5">
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-3/4 animate-pulse" />
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-12 pb-1.5 pl-3.5">
<div className="flex min-w-0 grow items-center gap-1.5">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
</div>
<div className="absolute right-1.5 bottom-1 flex h-10.5 w-8 items-center justify-center">
<SkeletonRectangle className="my-0 h-1 w-4 animate-pulse rounded-full" />
</div>
</div>
)
}
function DeploymentsListSkeleton() {
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
<InstanceCardSkeleton key={key} />
))
}
function DeploymentsSearchInput() {
const { t } = useTranslation('deployments')
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
function handleKeywordsChange(next: string) {
void setKeywords(next.trim() ? next : null, {
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
})
}
return (
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-50"
placeholder={t('filter.searchPlaceholder')}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
)
}
function DeploymentsListControls() {
return (
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-end gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex items-center gap-2">
<EnvironmentFilter />
<DeploymentsSearchInput />
</div>
</div>
)
}
export function DeploymentsList() {
const { t } = useTranslation('deployments')
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const queryKeywords = keywords.trim()
const requestedEnvironmentId = envFilter !== 'all' && envFilter !== 'not-deployed'
? envFilter
: undefined
const {
data,
error,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
...consoleQuery.enterprise.appDeploy.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(requestedEnvironmentId ? { environmentId: requestedEnvironmentId } : {}),
...(envFilter === 'not-deployed' ? { notDeployed: true } : {}),
...(queryKeywords ? { query: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const pages = data?.pages ?? []
const apps = pages.flatMap(page => page.data ?? [])
const showSkeleton = isLoading || (isFetching && pages.length === 0)
useEffect(() => {
if (!hasNextPage || isLoading || isFetchingNextPage || error)
return
const anchor = anchorRef.current
const container = containerRef.current
if (!anchor || !container)
return
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting)
void fetchNextPage()
}, {
root: container,
rootMargin: '160px',
threshold: 0.1,
})
observer.observe(anchor)
return () => observer.disconnect()
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<DeploymentsListControls />
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<NewInstanceCard />
{showSkeleton
? <DeploymentsListSkeleton />
: isError
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
: apps.length === 0
? <DeploymentsListState>{t('list.empty')}</DeploymentsListState>
: apps.map(app => (
<InstanceCard
key={app.id}
app={app}
/>
))}
{isFetchingNextPage && <DeploymentsListSkeleton />}
</div>
<div ref={anchorRef} className="h-0" />
<div className="py-4" />
</div>
)
}