This commit is contained in:
Stephen Zhou 2026-05-07 19:14:04 +08:00
parent 04124edd70
commit ea6e7a9ed0
No known key found for this signature in database
8 changed files with 204 additions and 186 deletions

View File

@ -8,9 +8,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { envFilterQueryState } from './query-state'
export type EnvironmentFilterOption = {
type EnvironmentFilterOption = {
value: string
text: string
icon: ReactNode
@ -18,13 +23,64 @@ export type EnvironmentFilterOption = {
disabledReason?: string
}
export function EnvironmentFilter({ value, options, onChange }: {
value: string
options: EnvironmentFilterOption[]
onChange: (value: string) => void
}) {
type FilterEnvironment = {
id: string
name: string
disabled?: boolean
disabledReason?: string
}
function getEnvironmentId(env: FilterEnvironment) {
return env.id
}
function getEnvironmentFilterOption(env: FilterEnvironment): EnvironmentFilterOption {
return {
value: env.id,
text: env.name,
icon: <span className="i-ri-stack-line h-[14px] w-[14px]" />,
disabled: env.disabled,
disabledReason: env.disabledReason,
}
}
export function EnvironmentFilter() {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === value) ?? options[0]
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
const { data: environmentOptionsReply } = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions())
const environmentOptions = environmentOptionsReply?.environments ?? []
function getFilterEnvironment(env: (typeof environmentOptions)[number]): FilterEnvironment[] {
if (!env.id)
return []
return [{
id: env.id,
name: env.name || env.id,
disabled: env.deployable === false,
disabledReason: env.disabledReason,
}]
}
const environments = environmentOptions.flatMap(getFilterEnvironment)
const envIdSet = new Set(environments.map(getEnvironmentId))
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed' || envIdSet.has(envFilter)
? envFilter
: 'all'
const filterOptions: EnvironmentFilterOption[] = [
{
value: 'all',
text: t('filter.allEnvs'),
icon: <span className="i-ri-apps-2-line h-[14px] w-[14px]" />,
},
...environments.map(getEnvironmentFilterOption),
{
value: 'not-deployed',
text: t('filter.notDeployed'),
icon: <span className="i-ri-inbox-line h-[14px] w-[14px]" />,
},
]
const selectedOption = filterOptions.find(option => option.value === activeFilter) ?? filterOptions[0]
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
@ -51,13 +107,13 @@ export function EnvironmentFilter({ value, options, onChange }: {
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<div className="max-h-72 overflow-auto p-1">
{options.map(option => (
{filterOptions.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
if (option.disabled)
return
onChange(option.value)
void setEnvFilter(option.value)
setOpen(false)
}}
title={option.disabled ? option.disabledReason : undefined}
@ -71,7 +127,7 @@ export function EnvironmentFilter({ value, options, onChange }: {
>
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
<span className="grow truncate text-sm leading-5 text-text-tertiary">{option.text}</span>
{option.value === value && (
{option.value === activeFilter && (
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-secondary" />
)}
</DropdownMenuItem>

View File

@ -1,8 +1,9 @@
'use client'
import type { ChangeEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { debounce, parseAsString, useQueryState } from 'nuqs'
import { debounce, useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { consoleQuery } from '@/service/client'
@ -10,36 +11,59 @@ import { CreateInstanceModal } from '../components/create-instance-modal'
import { DeployDrawer } from '../components/deploy-drawer'
import { RollbackModal } from '../components/rollback-modal'
import { SOURCE_APPS_PAGE_SIZE } from '../data'
import { useDeploymentsStore } from '../store'
import {
deploymentSummariesFromList,
sourceAppsFromList,
} from '../utils'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
import { NewInstanceCard } from './new-instance-card'
import { envFilterQueryState, keywordsQueryState } from './query-state'
export function DeploymentsMain() {
function DeploymentsSearchInput() {
const { t } = useTranslation('deployments')
const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal)
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
const [envFilter, setEnvFilter] = useQueryState(
'env',
parseAsString.withDefault('all').withOptions({ history: 'push' }),
)
const [keywords, setKeywords] = useQueryState(
'keywords',
parseAsString.withDefault('').withOptions({ history: 'push' }),
)
const debouncedKeywords = useDebounce(keywords, { wait: 300 })
const queryKeywords = keywords.trim() ? debouncedKeywords : keywords
const handleKeywordsChange = (next: string) => {
function handleKeywordsChange(next: string) {
void setKeywords(next.trim() ? next : null, {
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
})
}
function handleKeywordsInputChange(e: ChangeEvent<HTMLInputElement>) {
handleKeywordsChange(e.target.value)
}
function handleKeywordsClear() {
handleKeywordsChange('')
}
return (
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
placeholder={t('filter.searchPlaceholder')}
value={keywords}
onChange={handleKeywordsInputChange}
onClear={handleKeywordsClear}
/>
)
}
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>
)
}
function DeploymentsList() {
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const debouncedKeywords = useDebounce(keywords, { wait: 300 })
const queryKeywords = keywords.trim() ? debouncedKeywords : keywords
const requestedEnvironmentId = envFilter !== 'all' && envFilter !== 'not-deployed'
? envFilter
: undefined
@ -54,79 +78,32 @@ export function DeploymentsMain() {
},
},
}))
const { data: environmentOptionsReply } = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions())
const apps = sourceAppsFromList(listQuery.data)
const summaries = deploymentSummariesFromList(listQuery.data)
const environments = environmentOptionsReply?.environments?.flatMap((env) => {
if (!env.id)
return []
return [{
id: env.id,
name: env.name || env.id,
disabled: env.deployable === false,
disabledReason: env.disabledReason,
}]
}) ?? []
const envIdSet = new Set(environments.map(e => e.id))
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed' || envIdSet.has(envFilter)
? envFilter
: 'all'
const filterOptions = [
{
value: 'all',
text: t('filter.allEnvs'),
icon: <span className="i-ri-apps-2-line h-[14px] w-[14px]" />,
},
...environments.map(env => ({
value: env.id,
text: env.name,
icon: <span className="i-ri-stack-line h-[14px] w-[14px]" />,
disabled: env.disabled,
disabledReason: env.disabledReason,
})),
{
value: 'not-deployed',
text: t('filter.notDeployed'),
icon: <span className="i-ri-inbox-line h-[14px] w-[14px]" />,
},
]
const apps = listQuery.data?.data ?? []
return (
<>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<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
value={activeFilter}
onChange={(next) => { void setEnvFilter(next) }}
options={filterOptions}
/>
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
placeholder={t('filter.searchPlaceholder')}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<NewInstanceCard onOpen={openCreateInstanceModal} />
{apps.map(app => (
<InstanceCard
key={app.id}
app={app}
summary={summaries[app.id]}
/>
))}
</div>
<div className="py-4" />
<div 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 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<NewInstanceCard />
{apps.map(app => app.id
? (
<InstanceCard
key={app.id}
app={app}
/>
)
: null)}
</div>
<div className="py-4" />
</div>
)
}
export function DeploymentsMain() {
return (
<>
<DeploymentsList />
<CreateInstanceModal />
<DeployDrawer />
<RollbackModal />

View File

@ -1,8 +1,7 @@
'use client'
import type { AppInstanceCard } from '@dify/contracts/enterprise/types.gen'
import type { MouseEvent } from 'react'
import type { AppInfo } from '../types'
import type { AppDeploymentSummary } from '@/features/deployments/types'
import type { AppModeEnum } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -21,9 +20,8 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useRouter } from '@/next/navigation'
import { useDeploymentsStore } from '../store'
export function InstanceCard({ app, summary }: {
app: AppInfo
summary?: AppDeploymentSummary
export function InstanceCard({ app }: {
app: AppInstanceCard
}) {
const { t } = useTranslation('deployments')
const router = useRouter()
@ -31,7 +29,13 @@ export function InstanceCard({ app, summary }: {
const [menuOpen, setMenuOpen] = useState(false)
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`)
if (!app.id)
return null
const appId = app.id
const appName = app.name ?? appId
const appMode = app.mode ?? 'workflow'
const navigateToDetail = () => router.push(`/deployments/${appId}/overview`)
const handleMenuAction = (e: MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
@ -41,14 +45,14 @@ export function InstanceCard({ app, summary }: {
}
const statusCount = (status: string) =>
summary?.statuses?.find(item => item.status === status)?.count ?? 0
app.statuses?.find(item => item.status === status)?.count ?? 0
const failedCount = statusCount('failed') + statusCount('deploy_failed')
const deployingCount = statusCount('deploying')
const readyCount = statusCount('ready')
const envCount = failedCount + deployingCount + readyCount
const lastDeployedAt = summary?.lastDeployedAt
? Date.parse(summary.lastDeployedAt)
const lastDeployedAt = app.lastDeployedAt
? Date.parse(app.lastDeployedAt)
: null
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
@ -83,7 +87,7 @@ export function InstanceCard({ app, summary }: {
return status || 'unknown'
}
const statusSummaryTooltip = summary?.statuses?.filter(item => item.count && item.status !== 'undeployed') ?? []
const statusSummaryTooltip = app.statuses?.filter(item => item.count && item.status !== 'undeployed') ?? []
const statusTooltip = primaryStatus === 'none'
? t('card.tooltip.notDeployed')
: (
@ -114,7 +118,7 @@ export function InstanceCard({ app, summary }: {
? 'bg-util-colors-warning-warning-500 animate-pulse'
: 'bg-util-colors-green-green-500'
const appModeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
const appModeLabel = t(`appMode.${appMode}`, { defaultValue: appMode })
return (
<div
@ -128,20 +132,19 @@ export function InstanceCard({ app, summary }: {
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.iconType}
iconType="emoji"
icon={app.icon}
background={app.iconBackground}
imageUrl={app.iconUrl}
/>
<AppTypeIcon
type={app.mode as unknown as AppModeEnum}
type={appMode as AppModeEnum}
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
className="h-3 w-3"
/>
</div>
<div className="w-0 grow py-px">
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
<div className="truncate" title={app.name}>{app.name}</div>
<div className="truncate" title={appName}>{appName}</div>
</div>
<div className="truncate text-[10px] leading-[18px] font-medium text-text-tertiary" title={appModeLabel}>
{appModeLabel}
@ -174,8 +177,8 @@ export function InstanceCard({ app, summary }: {
</Tooltip>
<div className="flex min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-apps-2-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
<span className="truncate" title={app.sourceAppName ?? app.name}>
{t('card.fromApp', { name: app.sourceAppName ?? app.name })}
<span className="truncate" title={app.sourceAppName ?? appName}>
{t('card.fromApp', { name: app.sourceAppName ?? appName })}
</span>
</div>
</div>
@ -214,7 +217,7 @@ export function InstanceCard({ app, summary }: {
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[216px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appInstanceId: app.id }))}
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appInstanceId: appId }))}
>
<span className="system-sm-regular text-text-secondary">{t('card.menu.deploy')}</span>
</DropdownMenuItem>

View File

@ -2,6 +2,7 @@
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { useDeploymentsStore } from '../store'
type NewInstanceActionProps = {
icon: string
@ -37,10 +38,10 @@ function NewInstanceAction({ icon, label, disabled, onClick }: NewInstanceAction
)
}
export function NewInstanceCard({ onOpen }: {
onOpen: () => void
}) {
export function NewInstanceCard() {
const { t } = useTranslation('deployments')
const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal)
return (
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg">
<div className="grow rounded-t-xl p-2">
@ -50,7 +51,7 @@ export function NewInstanceCard({ onOpen }: {
<NewInstanceAction
icon="i-ri-stack-line"
label={t('newInstance.fromStudio')}
onClick={onOpen}
onClick={openCreateInstanceModal}
/>
<NewInstanceAction
icon="i-ri-file-code-line"

View File

@ -0,0 +1,4 @@
import { parseAsString } from 'nuqs'
export const envFilterQueryState = parseAsString.withDefault('all').withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })

View File

@ -1,7 +1,8 @@
'use client'
import type { AppInstanceBasicInfo, AppInstanceCard } from '@dify/contracts/enterprise/types.gen'
import type { NavItem } from '@/app/components/header/nav/nav-selector'
import type { AppIconType, AppModeEnum } from '@/types/app'
import type { AppModeEnum } from '@/types/app'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Nav from '@/app/components/header/nav'
@ -9,10 +10,40 @@ import { useParams, useRouter, useSelectedLayoutSegment } from '@/next/navigatio
import { consoleQuery } from '@/service/client'
import { SOURCE_APPS_PAGE_SIZE } from '../data'
import { useDeploymentsStore } from '../store'
import {
sourceAppsFromList,
toAppInfoFromOverview,
} from '../utils'
function navItemFromListApp(app: AppInstanceCard): NavItem[] {
if (!app.id || !app.name)
return []
return [{
id: app.id,
name: app.name,
link: `/deployments/${app.id}/overview`,
icon_type: 'emoji',
icon: app.icon ?? '',
icon_background: app.iconBackground ?? null,
icon_url: null,
mode: app.mode as AppModeEnum | undefined,
}]
}
function navItemFromOverview(instance?: AppInstanceBasicInfo): NavItem | undefined {
if (!instance?.id)
return undefined
const name = instance.name ?? instance.id
return {
id: instance.id,
name,
link: `/deployments/${instance.id}/overview`,
icon_type: 'emoji',
icon: instance.icon ?? '',
icon_background: instance.iconBackground ?? null,
icon_url: null,
mode: instance.mode as AppModeEnum | undefined,
}
}
export function DeploymentsNav() {
const { t } = useTranslation()
@ -28,7 +59,7 @@ export function DeploymentsNav() {
? { params: { appInstanceId: instanceId } }
: skipToken,
enabled: isActive && Boolean(instanceId),
select: data => toAppInfoFromOverview(data.instance),
select: data => data.instance,
}))
const listQuery = useQuery(consoleQuery.enterprise.appDeploy.listAppInstances.queryOptions({
@ -40,24 +71,13 @@ export function DeploymentsNav() {
},
enabled: isActive,
}))
const apps = sourceAppsFromList(listQuery.data)
const appNavItems = listQuery.data?.data?.flatMap(navItemFromListApp) ?? []
const currentNavItem = navItemFromOverview(currentInstance)
const navApps = currentInstance && !apps.some(app => app.id === currentInstance.id)
? [...apps, currentInstance]
: apps
const navigationItems: NavItem[] = isActive
? navApps.map((app) => {
return {
id: app.id,
name: app.name,
link: `/deployments/${app.id}/overview`,
icon_type: (app.iconType ?? null) as AppIconType | null,
icon: app.icon ?? '',
icon_background: app.iconBackground ?? null,
icon_url: app.iconUrl ?? null,
mode: app.mode as unknown as AppModeEnum | undefined,
}
})
? currentNavItem && !appNavItems.some(item => item.id === currentNavItem.id)
? [...appNavItems, currentNavItem]
: appNavItems
: []
const curNav = instanceId

View File

@ -44,16 +44,6 @@ type ConsoleUser = EnterpriseContract.ConsoleUser & {
displayName?: string
}
export type AppDeploymentSummary = EnterpriseContract.AppInstanceCard & {
createdAt?: Timestamp
description?: string
status?: string
}
export type ListAppDeploymentsReply = Omit<EnterpriseContract.ListAppInstancesReply, 'data'> & {
data?: AppDeploymentSummary[]
}
export type AppInstanceOverview = EnterpriseContract.AppInstanceBasicInfo
export type RuntimeBindingDisplay = EnterpriseContract.ReleaseRuntimeBinding & {

View File

@ -1,6 +1,5 @@
import type {
AccessPermissionKind,
AppDeploymentSummary,
AppInfo,
AppInstanceOverview,
AppMode,
@ -8,7 +7,6 @@ import type {
ConsoleReleaseSummary,
EnvironmentDeploymentRow,
EnvironmentOption,
ListAppDeploymentsReply,
ListDeploymentEnvironmentOptionsReply,
RuntimeBindingDisplay,
} from './types'
@ -136,23 +134,6 @@ export function deployedRows(rows?: EnvironmentDeploymentRow[]) {
}) ?? []
}
export function toAppInfoFromSummary(summary: AppDeploymentSummary): AppInfo | undefined {
if (!summary.id || !summary.name)
return undefined
return {
id: summary.id,
name: summary.name,
mode: (summary.mode || 'workflow') as AppMode,
iconType: 'emoji',
icon: summary.icon,
iconBackground: summary.iconBackground,
sourceAppName: summary.sourceAppName,
sourceAppAvailable: summary.sourceAppAvailable,
canCreateRelease: summary.canCreateRelease,
}
}
export function toAppInfoFromOverview(instance?: AppInstanceOverview): AppInfo | undefined {
if (!instance?.id)
return undefined
@ -172,20 +153,6 @@ export function toAppInfoFromOverview(instance?: AppInstanceOverview): AppInfo |
}
}
export function sourceAppsFromList(response?: ListAppDeploymentsReply) {
return (response?.data ?? [])
.map(toAppInfoFromSummary)
.filter((app): app is AppInfo => Boolean(app))
}
export function deploymentSummariesFromList(response?: ListAppDeploymentsReply): Record<string, AppDeploymentSummary> {
return Object.fromEntries(
(response?.data ?? [])
.filter(summary => summary.id)
.map(summary => [summary.id!, summary]),
)
}
export function environmentOptionsFromOptionsReply(response?: ListDeploymentEnvironmentOptionsReply): EnvironmentOption[] {
return response?.environments
?.filter(environment => environment.id)