mirror of
https://github.com/langgenius/dify.git
synced 2026-06-01 06:28:14 +08:00
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import type {
|
|
AccessChannels,
|
|
AppInstance,
|
|
EnvironmentDeployment,
|
|
Release,
|
|
} from '@dify/contracts/enterprise/types.gen'
|
|
import type { ReactElement } from 'react'
|
|
import type { InstanceDetailTabKey } from '../detail/tabs'
|
|
import { Button } from '@langgenius/dify-ui/button'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useSetAtom } from 'jotai'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
|
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
|
import Link from '@/next/link'
|
|
import { consoleQuery } from '@/service/client'
|
|
import { EnvironmentDeploymentBadge } from '../deployment-ui'
|
|
import { deploymentStatusLabelKey } from '../deployment-ui-utils'
|
|
import { CreateReleaseControl } from '../detail/versions-tab/create-release-control'
|
|
import { environmentName } from '../environment'
|
|
import { formatDate, releaseLabel } from '../release'
|
|
import {
|
|
deploymentStatus,
|
|
deploymentStatusPollingInterval,
|
|
isUndeployedDeploymentRow,
|
|
} from '../runtime-status'
|
|
import { openDeployDrawerAtom } from '../store'
|
|
|
|
const VISIBLE_ENVIRONMENT_COUNT = 3
|
|
const CARD_RELEASE_QUERY_PAGE_SIZE = 1
|
|
|
|
function getInstanceTabHref(appInstanceId: string, tabKey: InstanceDetailTabKey) {
|
|
return `/deployments/${appInstanceId}/${tabKey}`
|
|
}
|
|
|
|
function hasEnvironment(row: EnvironmentDeployment) {
|
|
return Boolean(row.environment?.id)
|
|
}
|
|
|
|
function isActiveDeployment(row: EnvironmentDeployment) {
|
|
return hasEnvironment(row) && !isUndeployedDeploymentRow(row)
|
|
}
|
|
|
|
function pickLatestRelease(rows: Release[]): Release | undefined {
|
|
return [...rows].sort((a, b) => {
|
|
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0
|
|
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0
|
|
return bTime - aTime
|
|
})[0]
|
|
}
|
|
|
|
function isReleaseDeployed(release: Release | undefined, rows: EnvironmentDeployment[]) {
|
|
if (!release?.id)
|
|
return false
|
|
|
|
return rows.some(row => row.currentRelease?.id === release.id)
|
|
}
|
|
|
|
function releaseSourceLabel(release: Release | undefined, t: ReturnType<typeof useTranslation<'deployments'>>['t']) {
|
|
if (release?.source === 'RELEASE_SOURCE_SOURCE_APP' || release?.sourceAppId)
|
|
return t('versions.sourceAppOption')
|
|
if (release?.source === 'RELEASE_SOURCE_UPLOAD')
|
|
return t('versions.manualDslOption')
|
|
return '—'
|
|
}
|
|
|
|
function ReleaseMetaTooltip({ release, deployed, children }: {
|
|
release?: Release
|
|
deployed: boolean
|
|
children: ReactElement
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
|
|
if (!release?.id)
|
|
return children
|
|
|
|
const rows = [
|
|
{ label: t('card.tooltip.releaseName'), value: releaseLabel(release) },
|
|
{ label: t('card.tooltip.deploymentStatus'), value: deployed ? t('card.tooltip.deployed') : t('card.tooltip.notDeployedShort') },
|
|
{ label: t('card.tooltip.source'), value: releaseSourceLabel(release, t) },
|
|
{ label: t('card.tooltip.createdAt'), value: formatDate(release.createdAt) },
|
|
]
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger render={children} />
|
|
<TooltipContent>
|
|
<div className="flex min-w-48 flex-col gap-1">
|
|
{rows.map(row => (
|
|
<div key={row.label} className="flex justify-between gap-4">
|
|
<span className="shrink-0 text-text-tertiary">{row.label}</span>
|
|
<span className="min-w-0 truncate text-right text-text-secondary">{row.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
function EnvironmentChip({ row }: {
|
|
row: EnvironmentDeployment
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
const name = environmentName(row.environment)
|
|
const status = deploymentStatus(row)
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={(
|
|
<EnvironmentDeploymentBadge row={row} className="max-w-44" />
|
|
)}
|
|
/>
|
|
<TooltipContent>
|
|
<div className="flex min-w-40 flex-col gap-1">
|
|
<div className="flex justify-between gap-3">
|
|
<span className="truncate text-text-secondary">{name}</span>
|
|
<span className="shrink-0">{t(deploymentStatusLabelKey(status))}</span>
|
|
</div>
|
|
{row.currentRelease?.id && (
|
|
<div className="flex justify-between gap-3 text-text-tertiary">
|
|
<span>{t('card.tooltip.release')}</span>
|
|
<span className="font-mono">{releaseLabel(row.currentRelease)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
function EnvironmentOverflow({ rows }: {
|
|
rows: EnvironmentDeployment[]
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={(
|
|
<span className="inline-flex h-5 cursor-default items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
|
|
{t('card.envOverflow', { count: rows.length })}
|
|
</span>
|
|
)}
|
|
/>
|
|
<TooltipContent>
|
|
<div className="flex min-w-40 flex-col gap-1">
|
|
{rows.map(row => (
|
|
<div key={row.environment?.id} className="flex justify-between gap-3">
|
|
<span className="truncate text-text-secondary">{environmentName(row.environment)}</span>
|
|
<span className="shrink-0">{t(deploymentStatusLabelKey(deploymentStatus(row)))}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
function DeploymentStatusContent({
|
|
rows,
|
|
isLoading,
|
|
hasError,
|
|
emptyAction,
|
|
}: {
|
|
rows: EnvironmentDeployment[]
|
|
isLoading: boolean
|
|
hasError: boolean
|
|
emptyAction?: ReactElement
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
const visibleRows = rows.slice(0, VISIBLE_ENVIRONMENT_COUNT)
|
|
const overflowRows = rows.slice(VISIBLE_ENVIRONMENT_COUNT)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
|
|
<SkeletonRectangle className="my-0 h-5 w-24 animate-pulse rounded-md" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (hasError) {
|
|
return (
|
|
<span className="system-xs-regular text-text-tertiary">
|
|
{t('common.loadFailed')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
if (rows.length > 0) {
|
|
return (
|
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
{visibleRows.map(row => (
|
|
<EnvironmentChip key={row.environment?.id} row={row} />
|
|
))}
|
|
{overflowRows.length > 0 && <EnvironmentOverflow rows={overflowRows} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (emptyAction)
|
|
return <div className="flex min-w-0 items-center">{emptyAction}</div>
|
|
|
|
return null
|
|
}
|
|
|
|
function DeploymentAccessLinks({ appInstanceId, access, isLoading }: {
|
|
appInstanceId: string
|
|
access?: AccessChannels
|
|
isLoading?: boolean
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div role="group" aria-label={t('overview.accessStatus')} className="flex min-w-0 grow items-center gap-2">
|
|
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" />
|
|
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const links = [
|
|
access?.webAppEnabled
|
|
? {
|
|
key: 'webapp',
|
|
href: getInstanceTabHref(appInstanceId, 'access'),
|
|
label: t('card.access.webApp'),
|
|
icon: 'i-ri-global-line',
|
|
}
|
|
: undefined,
|
|
access?.webAppEnabled
|
|
? {
|
|
key: 'cli',
|
|
href: getInstanceTabHref(appInstanceId, 'access'),
|
|
label: t('card.access.cli'),
|
|
icon: 'i-ri-terminal-box-line',
|
|
}
|
|
: undefined,
|
|
access?.developerApiEnabled
|
|
? {
|
|
key: 'api-tokens',
|
|
href: getInstanceTabHref(appInstanceId, 'api-tokens'),
|
|
label: t('card.access.api'),
|
|
icon: 'i-ri-code-s-slash-line',
|
|
}
|
|
: undefined,
|
|
].filter((link): link is { key: string, href: string, label: string, icon: string } => Boolean(link))
|
|
|
|
if (links.length === 0)
|
|
return <div role="group" aria-label={t('overview.accessStatus')} className="min-w-0 grow" />
|
|
|
|
return (
|
|
<div role="group" aria-label={t('overview.accessStatus')} className="flex min-w-0 grow items-center gap-2">
|
|
{links.map(link => (
|
|
<Tooltip key={link.key}>
|
|
<TooltipTrigger
|
|
render={(
|
|
<Link
|
|
href={link.href}
|
|
aria-label={link.label}
|
|
className="inline-flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
|
>
|
|
<span aria-hidden className={cn('size-3.5', link.icon)} />
|
|
</Link>
|
|
)}
|
|
/>
|
|
<TooltipContent>{link.label}</TooltipContent>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function InstanceCard({ app }: {
|
|
app: AppInstance
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
const { formatTimeFromNow } = useFormatTimeFromNow()
|
|
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
|
const appInstanceId = app.id ?? ''
|
|
const appName = app.name ?? appInstanceId
|
|
const detailHref = getInstanceTabHref(appInstanceId, 'overview')
|
|
const input = { params: { appInstanceId } }
|
|
|
|
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
|
|
input,
|
|
enabled: Boolean(appInstanceId),
|
|
}))
|
|
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({
|
|
input,
|
|
enabled: Boolean(appInstanceId),
|
|
}))
|
|
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
|
|
input: {
|
|
...input,
|
|
query: {
|
|
pageNumber: 1,
|
|
resultsPerPage: CARD_RELEASE_QUERY_PAGE_SIZE,
|
|
},
|
|
},
|
|
enabled: Boolean(appInstanceId),
|
|
}))
|
|
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
|
|
input,
|
|
enabled: Boolean(appInstanceId),
|
|
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
|
|
}))
|
|
|
|
if (!app.id)
|
|
return null
|
|
|
|
const description = (instanceQuery.data?.appInstance?.description ?? app.description)?.trim()
|
|
const access = accessChannelsQuery.data?.accessChannels
|
|
const releaseRows = releaseHistoryQuery.data?.data?.filter((release): release is Release & { id: string } => Boolean(release.id)) ?? []
|
|
const hasRelease = releaseRows.length > 0
|
|
const activeDeploymentRows = environmentDeploymentsQuery.data?.data?.filter(isActiveDeployment) ?? []
|
|
const latestRelease = pickLatestRelease(releaseRows)
|
|
const latestReleaseTime = latestRelease?.createdAt
|
|
const latestReleaseTimeMs = latestReleaseTime ? Date.parse(latestReleaseTime) : Number.NaN
|
|
const latestReleaseDeployed = isReleaseDeployed(latestRelease, activeDeploymentRows)
|
|
const releaseMeta = latestRelease
|
|
? [
|
|
releaseLabel(latestRelease),
|
|
Number.isNaN(latestReleaseTimeMs) ? undefined : formatTimeFromNow(latestReleaseTimeMs),
|
|
].filter(Boolean).join(' · ')
|
|
: t('card.notDeployed')
|
|
const releaseHistoryIsLoading = releaseHistoryQuery.isLoading
|
|
const statusIsLoading = environmentDeploymentsQuery.isLoading || (!activeDeploymentRows.length && releaseHistoryQuery.isLoading)
|
|
const statusHasError = environmentDeploymentsQuery.isError || releaseHistoryQuery.isError
|
|
const showDeployAction = !statusIsLoading && !statusHasError && hasRelease && activeDeploymentRows.length === 0
|
|
const showFooterCreateReleaseAction = !releaseHistoryIsLoading && !statusIsLoading && !statusHasError && !hasRelease
|
|
|
|
return (
|
|
<div
|
|
className="group relative col-span-1 inline-flex min-h-44 min-w-0 cursor-default flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs transition-all duration-200 ease-in-out hover:border-components-panel-border-subtle hover:shadow-md"
|
|
>
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<Link
|
|
href={detailHref}
|
|
className="block min-w-0 rounded-t-xl px-4 pt-4 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
|
>
|
|
<h3 className="truncate title-md-semi-bold text-text-primary" title={appName}>
|
|
{appName}
|
|
</h3>
|
|
{instanceQuery.isLoading
|
|
? (
|
|
<div className="mt-2 flex min-h-9 flex-col gap-1.5">
|
|
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
|
|
<SkeletonRectangle className="my-0 h-3 w-3/5 animate-pulse" />
|
|
</div>
|
|
)
|
|
: (
|
|
description
|
|
? (
|
|
<p
|
|
className="mt-2 line-clamp-2 min-h-9 system-xs-regular text-text-tertiary"
|
|
title={description}
|
|
>
|
|
{description}
|
|
</p>
|
|
)
|
|
: <div className="mt-2 min-h-9" />
|
|
)}
|
|
</Link>
|
|
|
|
<div role="group" aria-label={t('card.tooltip.deploymentStatus')} className="min-h-8 px-4 pt-2">
|
|
<DeploymentStatusContent
|
|
rows={activeDeploymentRows}
|
|
isLoading={statusIsLoading}
|
|
hasError={statusHasError}
|
|
emptyAction={showDeployAction
|
|
? (
|
|
<Button
|
|
variant="secondary-accent"
|
|
size="small"
|
|
className="max-w-full"
|
|
onClick={() => openDeployDrawer({ appInstanceId })}
|
|
>
|
|
<span className="truncate">{t('card.menu.deploy')}</span>
|
|
</Button>
|
|
)
|
|
: undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-auto flex min-h-11 min-w-0 items-center gap-3 border-t border-divider-subtle px-4 py-2">
|
|
{showFooterCreateReleaseAction
|
|
? (
|
|
<div className="-ml-2 flex min-w-0 grow items-center">
|
|
<CreateReleaseControl
|
|
appInstanceId={appInstanceId}
|
|
variant="secondary-accent"
|
|
label={t('card.createFirstRelease')}
|
|
className="max-w-full"
|
|
/>
|
|
</div>
|
|
)
|
|
: <DeploymentAccessLinks appInstanceId={appInstanceId} access={access} isLoading={accessChannelsQuery.isLoading} />}
|
|
<ReleaseMetaTooltip release={latestRelease} deployed={latestReleaseDeployed}>
|
|
<Link
|
|
href={latestRelease ? getInstanceTabHref(appInstanceId, 'releases') : getInstanceTabHref(appInstanceId, 'instances')}
|
|
className="min-w-0 shrink truncate text-right system-xs-medium text-text-secondary hover:text-text-primary"
|
|
>
|
|
{releaseMeta}
|
|
</Link>
|
|
</ReleaseMetaTooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|