This commit is contained in:
Stephen Zhou 2026-04-29 23:26:07 +08:00
parent e8ec7c7ff5
commit 663818f411
No known key found for this signature in database
15 changed files with 129 additions and 69 deletions

View File

@ -1,5 +1,4 @@
'use client'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import DeploymentsMain from '@/features/deployments/list'
import useDocumentTitle from '@/hooks/use-document-title'
@ -10,4 +9,4 @@ const DeploymentsPage = () => {
return <DeploymentsMain />
}
export default React.memo(DeploymentsPage)
export default DeploymentsPage

View File

@ -7,7 +7,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
@ -46,8 +45,6 @@ export const AppPicker: FC<AppPickerProps> = ({ apps, isLoading, value, onChange
const triggerRef = useRef<HTMLButtonElement>(null)
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined)
const selected = useMemo(() => apps.find(a => a.id === value), [apps, value])
const filtered = useMemo(() => {
const q = keywords.trim().toLowerCase()
if (!q)
@ -71,6 +68,8 @@ export const AppPicker: FC<AppPickerProps> = ({ apps, isLoading, value, onChange
)
}
const selected = apps.find(a => a.id === value)
if (apps.length === 0) {
return (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">

View File

@ -4,7 +4,6 @@ import type { FC } from 'react'
import type { EnvironmentOption } from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { environmentHealth, environmentMode, environmentName } from '../../utils'
import { HealthBadge, ModeBadge } from '../status-badge'
@ -36,10 +35,7 @@ type SelectProps = {
export const DeploymentSelect: FC<SelectProps> = ({ value, onChange, options, placeholder }) => {
const { t } = useTranslation('deployments')
const selectedOption = useMemo(
() => options.find(option => option.value === value),
[options, value],
)
const selectedOption = options.find(option => option.value === value)
return (
<Select
@ -71,17 +67,6 @@ export const DeploymentSelect: FC<SelectProps> = ({ value, onChange, options, pl
)
}
type LabeledSelectProps = SelectProps & { label: string }
export const LabeledSelect: FC<LabeledSelectProps> = ({ label, ...rest }) => (
<div className="flex items-center gap-2">
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{label}</span>
<div className="min-w-0 flex-1">
<DeploymentSelect {...rest} />
</div>
</div>
)
type EnvironmentRowProps = { env: EnvironmentOption }
export const EnvironmentRow: FC<EnvironmentRowProps> = ({ env }) => (

View File

@ -9,7 +9,6 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useSourceApps } from '../hooks/use-source-apps'
import { useDeploymentAppData, useDeploymentInstance, useDeploymentsStore } from '../store'

View File

@ -2,7 +2,6 @@
import type { FC } from 'react'
import type { DeployStatus, EnvironmentHealth, EnvironmentMode } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type StatusBadgeProps = {

View File

@ -134,10 +134,6 @@ export const deploymentAppDataQueryOptions = (appId: string) =>
staleTime: DEPLOYMENT_APP_DATA_STALE_TIME,
})
export const refreshDeploymentAppData = async (appId: string): Promise<DeploymentAppData> => {
return fetchDeploymentAppData(appId)
}
const wait = (delay: number) => new Promise(resolve => setTimeout(resolve, delay))
export const refreshDeploymentAppDataWhenReady = async (appId: string): Promise<DeploymentAppData> => {
@ -148,7 +144,7 @@ export const refreshDeploymentAppDataWhenReady = async (appId: string): Promise<
await wait(delay)
try {
return await refreshDeploymentAppData(appId)
return await fetchDeploymentAppData(appId)
}
catch (error) {
lastError = error

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type {
AccessPermission,
ConsoleEnvironmentSummary,
} from '@/contract/console/deployments'
import { useMemo } from 'react'
@ -15,6 +16,8 @@ import { DeveloperApiSection } from './access-tab/developer-api-section'
import { AccessPermissionsSection } from './access-tab/permissions-section'
import { getUrlOrigin } from './access-tab/url'
const EMPTY_ACCESS_PERMISSIONS: AccessPermission[] = []
function uniqueEnvironments(environments: (ConsoleEnvironmentSummary | undefined)[]) {
return environments.filter((environment, index): environment is ConsoleEnvironmentSummary => {
if (!environment?.id)
@ -41,10 +44,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
() => deployedRows(appData?.environmentDeployments.data),
[appData?.environmentDeployments.data],
)
const policies = useMemo(
() => accessConfig?.permissions ?? [],
[accessConfig?.permissions],
)
const policies = accessConfig?.permissions ?? EMPTY_ACCESS_PERMISSIONS
const deployedEnvs = useMemo(
() => uniqueEnvironments([
...deploymentRows.map(row => row.environment),
@ -68,7 +68,6 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
const webappRows = accessConfig?.accessChannels?.webappRows?.filter(row => row.url) ?? []
const runEnabled = accessConfig?.accessChannels?.enabled ?? false
const visibleCreatedApiToken = createdApiToken?.appId === appId ? createdApiToken : undefined
const webappChannelVersion = 0
const cliDomain = getUrlOrigin(accessConfig?.accessChannels?.cli?.url)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
@ -85,14 +84,14 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
webappRows={webappRows}
cliDomain={cliDomain}
cliDocsUrl={cliDocsUrl}
onToggle={enabled => toggleAccessChannel(appId, 'webapp', enabled, webappChannelVersion)}
onToggle={enabled => toggleAccessChannel(appId, 'webapp', enabled)}
/>
<DeveloperApiSection
apiEnabled={apiEnabled}
environments={deployedEnvs}
apiKeys={apiKeys}
createdToken={visibleCreatedApiToken?.token}
onToggle={enabled => toggleAccessChannel(appId, 'api', enabled, 0)}
onToggle={enabled => toggleAccessChannel(appId, 'api', enabled)}
onGenerate={handleGenerateApiKey}
onRevoke={handleRevokeApiKey}
onClearCreatedToken={clearCreatedApiToken}

View File

@ -13,11 +13,8 @@ type AccessPermissionsSectionProps = {
onSetPolicy: (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
expectedVersion: number,
) => Promise<void>
}

View File

@ -304,11 +304,8 @@ type EnvironmentPermissionRowProps = {
onSetPolicy: (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
expectedVersion: number,
) => Promise<void>
}
@ -363,11 +360,8 @@ export const EnvironmentPermissionRow: FC<EnvironmentPermissionRowProps> = ({
await onSetPolicy(
appId,
environmentId,
'webapp',
true,
permissionKeyToAccessMode(nextKind),
nextKind === 'specific' ? policySubjects(nextSubjects) : [],
detailPolicy?.version ?? 0,
)
await policyQuery.refetch()
setDraft({})

View File

@ -1,8 +1,7 @@
'use client'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
@ -20,8 +19,8 @@ type SwitchableTab = 'deploy' | 'versions' | 'access' | 'settings'
type SectionProps = {
title: string
action?: React.ReactNode
children: React.ReactNode
action?: ReactNode
children: ReactNode
}
const Section: FC<SectionProps> = ({ title, action, children }) => (
@ -36,7 +35,7 @@ const Section: FC<SectionProps> = ({ title, action, children }) => (
type InfoRowProps = {
label: string
value: React.ReactNode
value: ReactNode
mono?: boolean
}

View File

@ -14,7 +14,6 @@ import {
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'

View File

@ -0,0 +1,109 @@
import type { EnvironmentDeploymentRow, ReleaseHistoryRow } from '@/contract/console/deployments'
import { describe, expect, it } from 'vitest'
import { getReleaseDeployments } from '../release-deployments'
describe('getReleaseDeployments', () => {
it('should prefer runtime deployment state when history has the same environment', () => {
// Arrange
const releaseRow = {
id: 'release-1',
deployedTo: [
{
environmentId: 'env-1',
environmentName: 'Production',
instanceStatus: 'failed',
},
],
} satisfies ReleaseHistoryRow
const deploymentRows = [
{
id: 'deployment-1',
environment: {
id: 'env-1',
name: 'Production',
},
status: 'ready',
currentRelease: {
id: 'release-1',
},
},
] satisfies EnvironmentDeploymentRow[]
// Act
const result = getReleaseDeployments(releaseRow, deploymentRows)
// Assert
expect(result).toEqual([
{
environmentId: 'env-1',
environmentName: 'Production',
state: 'active',
},
])
})
it('should merge history deployments with runtime deployments for different environments', () => {
// Arrange
const releaseRow = {
release: {
id: 'release-1',
},
deployedTo: [
{
environmentId: 'env-1',
environmentName: 'Production',
instanceStatus: 'ready',
},
],
} satisfies ReleaseHistoryRow
const deploymentRows = [
{
id: 'deployment-2',
environment: {
id: 'env-2',
name: 'Staging',
},
status: 'deploying',
currentRelease: {
id: 'release-1',
},
},
] satisfies EnvironmentDeploymentRow[]
// Act
const result = getReleaseDeployments(releaseRow, deploymentRows)
// Assert
expect(result).toEqual([
{
environmentId: 'env-2',
environmentName: 'Staging',
state: 'deploying',
},
{
environmentId: 'env-1',
environmentName: 'Production',
state: 'active',
},
])
})
it('should return no deployments when the release row has no release id', () => {
// Arrange
const releaseRow = {
deployedTo: [
{
environmentId: 'env-1',
environmentName: 'Production',
instanceStatus: 'ready',
},
],
} satisfies ReleaseHistoryRow
// Act
const result = getReleaseDeployments(releaseRow, [])
// Assert
expect(result).toEqual([])
})
})

View File

@ -36,8 +36,7 @@ function fromDeployedTo(item: DeployedToSummary): ReleaseDeployment | undefined
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
return items.filter((item, index) => {
const key = `${item.environmentId}-${item.state}`
return items.findIndex(candidate => `${candidate.environmentId}-${candidate.state}` === key) === index
return items.findIndex(candidate => candidate.environmentId === item.environmentId) === index
})
}
@ -63,5 +62,5 @@ export function getReleaseDeployments(row: ReleaseHistoryRow, deploymentRows: En
return items
})
return dedupeReleaseDeployments([...historyItems, ...runtimeItems])
return dedupeReleaseDeployments([...runtimeItems, ...historyItems])
}

View File

@ -90,10 +90,6 @@ const DeploymentsMain: FC = () => {
]
}, [environments, t])
const visibleInstances = useMemo(() => {
return apps
}, [apps])
return (
<>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
@ -117,7 +113,7 @@ const DeploymentsMain: FC = () => {
</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} />
{visibleInstances.map(app => (
{apps.map(app => (
<InstanceCard
key={app.id}
app={app}

View File

@ -18,7 +18,6 @@ import {
listAppDeployments,
patchAccessChannel,
patchDeveloperAPI,
refreshDeploymentAppData,
refreshDeploymentAppDataWhenReady,
toAppInfoFromOverview,
toAppInfoFromSummary,
@ -76,7 +75,6 @@ export type DeploymentsAction = {
createInstance: (params: CreateInstanceParams) => Promise<CreateInstanceResult>
updateInstance: (appId: string, patch: Pick<AppInfo, 'name' | 'description'>) => Promise<void>
switchSourceApp: (appId: string, nextAppId: string) => void
deleteInstance: (appId: string) => Promise<void>
startDeploy: (params: StartDeployParams) => Promise<void>
@ -87,15 +85,12 @@ export type DeploymentsAction = {
generateApiKey: (appId: string, environmentId: string) => Promise<void>
revokeApiKey: (appId: string, environmentId: string, apiKeyId: string) => Promise<void>
clearCreatedApiToken: () => void
toggleAccessChannel: (appId: string, channel: string, enabled: boolean, expectedVersion: number) => Promise<void>
toggleAccessChannel: (appId: string, channel: string, enabled: boolean) => Promise<void>
setEnvironmentAccessPolicy: (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
expectedVersion: number,
) => Promise<void>
}
@ -203,7 +198,7 @@ class DeploymentsActionImpl implements DeploymentsAction {
}
refreshAppData = async (appId: string) => {
const data = await refreshDeploymentAppData(appId)
const data = await fetchDeploymentAppData(appId)
this.applyAppData(data)
const app = toAppInfoFromOverview(data.overview.instance)
if (app)
@ -265,8 +260,6 @@ class DeploymentsActionImpl implements DeploymentsAction {
}))
}
switchSourceApp = () => undefined
deleteInstance = async (appId: string) => {
await deleteAppInstance(appId)
this.#set((state) => {
@ -359,8 +352,6 @@ class DeploymentsActionImpl implements DeploymentsAction {
setEnvironmentAccessPolicy = async (
appId: string,
environmentId: string,
_channel: string,
_enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
) => {