-
-
{t(`tabs.${activeTab}.name`)}
- {activeTab === 'api-tokens' && (
-
-
-
- )}
-
-
{t(`tabs.${activeTab}.description`)}
+
+
+
+
+
+
+
+
+
{t(`tabs.${activeTab}.name`)}
+ {activeTab === 'api-tokens' && (
+
+
+
+ )}
- {(activeTab === 'instances' || activeTab === 'releases') && (
-
- {activeTab === 'instances'
- ?
- : }
-
- )}
+
{t(`tabs.${activeTab}.description`)}
+ {(activeTab === 'instances' || activeTab === 'releases') && (
+
+ {activeTab === 'instances'
+ ?
+ : }
+
+ )}
-
- {children}
+
+ {children}
-
+
)
}
diff --git a/web/features/deployments/detail/instances/README.md b/web/features/deployments/detail/instances/README.md
new file mode 100644
index 00000000000..d358348eca3
--- /dev/null
+++ b/web/features/deployments/detail/instances/README.md
@@ -0,0 +1,18 @@
+# Deployment Instances
+
+Instances route for listing environment deployments and running deployment row actions.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| ----------------------------------- | ----------------------------------------------------------------------------------- |
+| `detail` | Reads detail-owned query state and runtime instance rows before composing sections. |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `detail/instances/environment-list` | Renders environment deployment rows. |
+| `detail/instances/header-actions` | Renders the new deployment action for empty instances. |
+
+## External Modules
+
+| Module | Why this module uses it |
+| ------------------------------ | --------------------------------------------------------------- |
+| `app/components/base/skeleton` | Reuses skeleton primitives for deployment table loading states. |
diff --git a/web/features/deployments/detail/instances/environment-list/README.md b/web/features/deployments/detail/instances/environment-list/README.md
new file mode 100644
index 00000000000..cc4154c4202
--- /dev/null
+++ b/web/features/deployments/detail/instances/environment-list/README.md
@@ -0,0 +1,15 @@
+# Deployment Instances Environment List
+
+Environment list section for rendering deployment rows and row-level status summaries on the instances route.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| ------------------------------ | ------------------------------------------------------------------------------ |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `detail/instances` | Uses instances-owned table column class names for deployment rows. |
+| `detail/instances/row-actions` | Renders row-level deployment actions for each environment. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx b/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx
similarity index 93%
rename from web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx
rename to web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx
index 1319a539efa..6bf6bde1fc5 100644
--- a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx
+++ b/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx
@@ -2,8 +2,6 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { useTranslation } from 'react-i18next'
-import { releaseCommit } from '../../shared/domain/release'
-import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import {
DetailTable,
DetailTableBody,
@@ -13,11 +11,11 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
-} from '../components/detail-table'
-import {
- DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
-} from '../components/detail-table-styles'
-import { DeploymentRowActions } from './deployment-row-actions'
+} from '../../../shared/components/detail-table'
+import { releaseCommit } from '../../../shared/domain/release'
+import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status'
+import { DeploymentRowActions } from '../row-actions/deployment-row-actions'
+import { DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles'
import { DeploymentStatusSummary } from './deployment-status-summary'
function EnvironmentSummary({ environment }: {
diff --git a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx b/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx
similarity index 95%
rename from web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx
rename to web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx
index e5b58e9051e..dfa30fdbe09 100644
--- a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx
+++ b/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx
@@ -5,8 +5,8 @@ import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { useTranslation } from 'react-i18next'
import {
isUndeployedDeploymentRow,
-} from '../../shared/domain/runtime-status'
-import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
+} from '../../../shared/domain/runtime-status'
+import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge'
export function DeploymentStatusSummary({ row }: {
row: EnvironmentDeployment
diff --git a/web/features/deployments/detail/instances/header-actions/README.md b/web/features/deployments/detail/instances/header-actions/README.md
new file mode 100644
index 00000000000..d48ed8714a5
--- /dev/null
+++ b/web/features/deployments/detail/instances/header-actions/README.md
@@ -0,0 +1,15 @@
+# Deployment Instances Header Actions
+
+Header action for opening the deploy drawer from the instances route.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| --------------- | ----------------------------------------------------------------------- |
+| `deploy-drawer` | Opens the deploy drawer for the first available undeployed environment. |
+| `route-state` | Reads the route app instance identity for deployment actions. |
+| `detail` | Reads detail-owned query state and runtime instance rows. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx
similarity index 72%
rename from web/features/deployments/detail/deploy-tab/new-deployment-button.tsx
rename to web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx
index b5a06665cdb..30531fde96b 100644
--- a/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx
+++ b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx
@@ -3,10 +3,12 @@
import { Button } from '@langgenius/dify-ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
-import { openDeployDrawerAtom } from '../../deploy-drawer/state'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
-import { deploymentEnvironmentDeploymentsQueryAtom } from '../state'
+import { openDeployDrawerAtom } from '../../../deploy-drawer/state'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+import {
+ deploymentEnvironmentDeploymentsQueryAtom,
+ deploymentRuntimeInstanceRowsAtom,
+} from '../../state'
export function NewDeploymentButton() {
const { t } = useTranslation('deployments')
@@ -33,7 +35,7 @@ export function NewDeploymentButton() {
export function NewDeploymentHeaderAction() {
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
- const rows = environmentDeploymentsQuery.data?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
+ const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom)
if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0)
return null
diff --git a/web/features/deployments/detail/deploy-tab/index.tsx b/web/features/deployments/detail/instances/index.tsx
similarity index 87%
rename from web/features/deployments/detail/deploy-tab/index.tsx
rename to web/features/deployments/detail/instances/index.tsx
index 4f0bdc5cb38..e33ce938d43 100644
--- a/web/features/deployments/detail/deploy-tab/index.tsx
+++ b/web/features/deployments/detail/instances/index.tsx
@@ -2,8 +2,6 @@
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
-import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
-import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
import {
DetailTable,
DetailTableBody,
@@ -13,13 +11,15 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
-} from '../components/detail-table'
+} from '../../shared/components/detail-table'
+import { DeploymentEmptyState, DeploymentStateMessage } from '../../shared/components/empty-state'
import {
- DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
-} from '../components/detail-table-styles'
-import { deploymentEnvironmentDeploymentsQueryAtom } from '../state'
-import { DeploymentEnvironmentList } from './deployment-environment-list'
-import { NewDeploymentButton } from './new-deployment-button'
+ deploymentEnvironmentDeploymentsQueryAtom,
+ deploymentRuntimeInstanceRowsAtom,
+} from '../state'
+import { DeploymentEnvironmentList } from './environment-list/deployment-environment-list'
+import { NewDeploymentButton } from './header-actions/new-deployment-button'
+import { DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES } from './table-styles'
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging']
@@ -87,11 +87,10 @@ function DeploymentEnvironmentListSkeleton() {
)
}
-export function DeployTab() {
+export function DeploymentInstances() {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
- const environmentDeployments = environmentDeploymentsQuery.data
- const rows = environmentDeployments?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
+ const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom)
const isLoading = environmentDeploymentsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError
diff --git a/web/features/deployments/detail/instances/row-actions/README.md b/web/features/deployments/detail/instances/row-actions/README.md
new file mode 100644
index 00000000000..8fad4a09abd
--- /dev/null
+++ b/web/features/deployments/detail/instances/row-actions/README.md
@@ -0,0 +1,15 @@
+# Deployment Instances Row Actions
+
+Row action menu and dialogs for deploying, redeploying, and undeploying an environment deployment.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| --------------- | ------------------------------------------------------------------------------ |
+| `deploy-drawer` | Opens the deploy drawer from row-level deploy and redeploy flows. |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `route-state` | Reads the route app instance identity for deployment mutations. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/deploy-tab/deployment-error-dialog.tsx b/web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx
similarity index 100%
rename from web/features/deployments/detail/deploy-tab/deployment-error-dialog.tsx
rename to web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx
diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx b/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx
similarity index 97%
rename from web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx
rename to web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx
index 29dc75222c2..9a2b89fa937 100644
--- a/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx
+++ b/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx
@@ -10,7 +10,7 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
+import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../../../shared/components/detail-table-styles'
export function DeploymentActionsDropdown({
currentReleaseId,
diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx b/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx
similarity index 93%
rename from web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx
rename to web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx
index f5c82f473a7..b6d9b1cbcea 100644
--- a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx
+++ b/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx
@@ -7,10 +7,10 @@ import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
-import { openDeployDrawerAtom } from '../../deploy-drawer/state'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
-import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
+import { openDeployDrawerAtom } from '../../../deploy-drawer/state'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+import { createDeploymentIdempotencyKey } from '../../../shared/domain/idempotency'
+import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status'
import { DeploymentErrorDialog } from './deployment-error-dialog'
import { DeploymentActionsDropdown } from './deployment-row-actions-menu'
import { UndeployDeploymentDialog } from './undeploy-deployment-dialog'
diff --git a/web/features/deployments/detail/deploy-tab/undeploy-deployment-dialog.tsx b/web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx
similarity index 100%
rename from web/features/deployments/detail/deploy-tab/undeploy-deployment-dialog.tsx
rename to web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx
diff --git a/web/features/deployments/detail/instances/table-styles.ts b/web/features/deployments/detail/instances/table-styles.ts
new file mode 100644
index 00000000000..aabaa9c9039
--- /dev/null
+++ b/web/features/deployments/detail/instances/table-styles.ts
@@ -0,0 +1,6 @@
+export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
+ actions: 'w-14 py-1.5 whitespace-nowrap',
+ currentRelease: '',
+ environment: 'whitespace-nowrap',
+ status: 'whitespace-nowrap',
+}
diff --git a/web/features/deployments/detail/overview-tab/card-styles.ts b/web/features/deployments/detail/overview-tab/card-styles.ts
deleted file mode 100644
index 07afcf3d0c2..00000000000
--- a/web/features/deployments/detail/overview-tab/card-styles.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { cn } from '@langgenius/dify-ui/cn'
-
-export const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4'
-
-export const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn(
- OVERVIEW_CARD_CLASS_NAME,
- 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
-)
-
-export const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary'
diff --git a/web/features/deployments/detail/overview/README.md b/web/features/deployments/detail/overview/README.md
new file mode 100644
index 00000000000..aafa58594e8
--- /dev/null
+++ b/web/features/deployments/detail/overview/README.md
@@ -0,0 +1,17 @@
+# Deployment Overview
+
+Overview route for release summary, environment status, and access summaries on a deployment app instance.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| ------------------------------------ | ------------------------------------------------------------------------------ |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `route-state` | Reads the route app instance identity for overview query, links, and actions. |
+| `detail/overview/access-summary` | Renders access and API token status summary sections. |
+| `detail/overview/environment-status` | Renders environment deployment status tiles. |
+| `detail/overview/release-summary` | Renders latest release summary and release creation entry point. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/overview/__tests__/state.spec.ts b/web/features/deployments/detail/overview/__tests__/state.spec.ts
new file mode 100644
index 00000000000..30df06f0855
--- /dev/null
+++ b/web/features/deployments/detail/overview/__tests__/state.spec.ts
@@ -0,0 +1,72 @@
+import type { Getter } from 'jotai'
+import { skipToken } from '@tanstack/react-query'
+import { atom, createStore } from 'jotai'
+import { describe, expect, it, vi } from 'vitest'
+import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
+
+type QueryOptions = {
+ enabled?: boolean
+ input?: unknown
+ queryKey?: readonly unknown[]
+}
+
+vi.mock('jotai-tanstack-query', () => ({
+ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
+ ...createOptions(get),
+ data: undefined,
+ isError: false,
+ isFetching: false,
+ isLoading: false,
+ isSuccess: false,
+ })),
+}))
+
+vi.mock('@/service/client', () => ({
+ consoleQuery: {
+ enterprise: {
+ appInstanceService: {
+ getAppInstanceOverview: {
+ queryOptions: (options: QueryOptions) => ({
+ ...options,
+ queryKey: ['getAppInstanceOverview', options.input],
+ }),
+ },
+ },
+ },
+ },
+}))
+
+async function loadState() {
+ return await import('../state')
+}
+
+function setDeploymentRoute(store: ReturnType
, appInstanceId = 'app-instance-1') {
+ store.set(setNextRouteStateAtom, {
+ pathname: `/deployments/${appInstanceId}/overview`,
+ params: { appInstanceId },
+ })
+}
+
+describe('deployment overview state', () => {
+ it('should disable overview query with skipToken until route state is ready', async () => {
+ const state = await loadState()
+ const store = createStore()
+
+ expect(store.get(state.deploymentOverviewQueryAtom)).toMatchObject({
+ enabled: false,
+ input: skipToken,
+ })
+ })
+
+ it('should build overview query input from route identity', async () => {
+ const state = await loadState()
+ const store = createStore()
+
+ setDeploymentRoute(store)
+
+ expect(store.get(state.deploymentOverviewQueryAtom)).toMatchObject({
+ enabled: true,
+ input: { params: { appInstanceId: 'app-instance-1' } },
+ })
+ })
+})
diff --git a/web/features/deployments/detail/overview/access-summary/README.md b/web/features/deployments/detail/overview/access-summary/README.md
new file mode 100644
index 00000000000..7d431372afd
--- /dev/null
+++ b/web/features/deployments/detail/overview/access-summary/README.md
@@ -0,0 +1,15 @@
+# Deployment Overview Access Summary
+
+Access summary section for showing webapp and API token status on the overview route.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| ----------------- | ------------------------------------------------------------------------------ |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `route-state` | Reads the route app instance identity for access summary links. |
+| `detail/overview` | Reuses overview card and icon styling shared by overview sections. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/overview-tab/access-status-section.tsx b/web/features/deployments/detail/overview/access-summary/access-status-section.tsx
similarity index 92%
rename from web/features/deployments/detail/overview-tab/access-status-section.tsx
rename to web/features/deployments/detail/overview/access-summary/access-status-section.tsx
index 0187315d35d..8f2fbb0bfa9 100644
--- a/web/features/deployments/detail/overview-tab/access-status-section.tsx
+++ b/web/features/deployments/detail/overview/access-summary/access-status-section.tsx
@@ -7,9 +7,8 @@ import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
-import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge'
type AccessStatusSectionProps = {
accessChannels?: AccessChannels
@@ -31,6 +30,12 @@ type AccessStatusItem = {
}
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli']
+const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4'
+const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn(
+ OVERVIEW_CARD_CLASS_NAME,
+ 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
+)
+const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary'
export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps) {
const { t } = useTranslation('deployments')
diff --git a/web/features/deployments/detail/overview/environment-status/README.md b/web/features/deployments/detail/overview/environment-status/README.md
new file mode 100644
index 00000000000..a00b4814cb6
--- /dev/null
+++ b/web/features/deployments/detail/overview/environment-status/README.md
@@ -0,0 +1,16 @@
+# Deployment Overview Environment Status
+
+Environment status section for previewing runtime deployments and opening deployment actions from the overview route.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| ----------------- | ------------------------------------------------------------------------------ |
+| `deploy-drawer` | Opens the deploy drawer from overview environment actions. |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `route-state` | Reads the route app instance identity for environment links and actions. |
+| `detail/overview` | Reuses overview card and icon styling shared by overview sections. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/overview-tab/environment-tile-utils.spec.ts b/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts
similarity index 99%
rename from web/features/deployments/detail/overview-tab/environment-tile-utils.spec.ts
rename to web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts
index 466862a5217..c7d9e65a06e 100644
--- a/web/features/deployments/detail/overview-tab/environment-tile-utils.spec.ts
+++ b/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts
@@ -6,7 +6,7 @@ import {
renderDriftTitle,
renderStatus,
resolveConfig,
-} from './environment-tile-utils'
+} from '../environment-tile-utils'
const t = ((key: string, options?: Record) => {
return options ? `${key}:${JSON.stringify(options)}` : key
diff --git a/web/features/deployments/detail/overview-tab/environment-strip.tsx b/web/features/deployments/detail/overview/environment-status/environment-strip.tsx
similarity index 92%
rename from web/features/deployments/detail/overview-tab/environment-strip.tsx
rename to web/features/deployments/detail/overview/environment-status/environment-strip.tsx
index 39af33bb45b..9267056f8fe 100644
--- a/web/features/deployments/detail/overview-tab/environment-strip.tsx
+++ b/web/features/deployments/detail/overview/environment-status/environment-strip.tsx
@@ -7,14 +7,14 @@ import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Link from '@/next/link'
-import { DeploymentEmptyState } from '../../components/empty-state'
-import { openDeployDrawerAtom } from '../../deploy-drawer/state'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
-import { OVERVIEW_CARD_CLASS_NAME } from './card-styles'
+import { openDeployDrawerAtom } from '../../../deploy-drawer/state'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+import { DeploymentEmptyState } from '../../../shared/components/empty-state'
+import { hasRuntimeInstanceDeployment } from '../../../shared/domain/runtime-status'
import { EnvironmentTile } from './environment-tile'
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4
+const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4'
type EnvironmentStripProps = {
rows: EnvironmentDeployment[]
diff --git a/web/features/deployments/detail/overview-tab/environment-tile-utils.ts b/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts
similarity index 98%
rename from web/features/deployments/detail/overview-tab/environment-tile-utils.ts
rename to web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts
index aba4ee23cdd..a6947d09698 100644
--- a/web/features/deployments/detail/overview-tab/environment-tile-utils.ts
+++ b/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts
@@ -2,7 +2,7 @@ import type { RuntimeInstanceStatus as RuntimeInstanceStatusValue } from '@dify/
import type { TFunction } from 'i18next'
import type { computeDrift } from './overview-drift'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
-import { isRuntimeDeploymentInProgress } from '../../shared/domain/runtime-status'
+import { isRuntimeDeploymentInProgress } from '../../../shared/domain/runtime-status'
export type TileKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed'
diff --git a/web/features/deployments/detail/overview-tab/environment-tile.tsx b/web/features/deployments/detail/overview/environment-status/environment-tile.tsx
similarity index 85%
rename from web/features/deployments/detail/overview-tab/environment-tile.tsx
rename to web/features/deployments/detail/overview/environment-status/environment-tile.tsx
index 49247461b29..ff3ae6da051 100644
--- a/web/features/deployments/detail/overview-tab/environment-tile.tsx
+++ b/web/features/deployments/detail/overview/environment-status/environment-tile.tsx
@@ -10,15 +10,14 @@ import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
-import { TitleTooltip } from '../../components/title-tooltip'
-import { openDeployDrawerAtom } from '../../deploy-drawer/state'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { releaseCommit } from '../../shared/domain/release'
-import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
+import { openDeployDrawerAtom } from '../../../deploy-drawer/state'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+import { TitleTooltip } from '../../../shared/components/title-tooltip'
+import { releaseCommit } from '../../../shared/domain/release'
+import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge'
import {
deploymentStatusLabelKey,
-} from '../../shared/ui/deployment-status-style'
-import { OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
+} from '../../../shared/ui/deployment-status-style'
import {
renderActionLabel,
renderDriftTitle,
@@ -27,6 +26,13 @@ import {
} from './environment-tile-utils'
import { computeDrift, latestReleaseId } from './overview-drift'
+const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4'
+const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn(
+ OVERVIEW_CARD_CLASS_NAME,
+ 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
+)
+const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary'
+
type EnvironmentTileProps = {
row: EnvironmentDeployment
releaseRows: Release[]
diff --git a/web/features/deployments/detail/overview-tab/overview-drift.ts b/web/features/deployments/detail/overview/environment-status/overview-drift.ts
similarity index 91%
rename from web/features/deployments/detail/overview-tab/overview-drift.ts
rename to web/features/deployments/detail/overview/environment-status/overview-drift.ts
index 862b1fd553c..79ffc500625 100644
--- a/web/features/deployments/detail/overview-tab/overview-drift.ts
+++ b/web/features/deployments/detail/overview/environment-status/overview-drift.ts
@@ -1,5 +1,5 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
-import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
+import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status'
export type Drift
= | { kind: 'undeployed' }
diff --git a/web/features/deployments/detail/overview-tab/index.tsx b/web/features/deployments/detail/overview/index.tsx
similarity index 90%
rename from web/features/deployments/detail/overview-tab/index.tsx
rename to web/features/deployments/detail/overview/index.tsx
index 9b0d2aae2a8..da486fe10ca 100644
--- a/web/features/deployments/detail/overview-tab/index.tsx
+++ b/web/features/deployments/detail/overview/index.tsx
@@ -3,13 +3,13 @@
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
-import { DeploymentStateMessage } from '../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
+import { DeploymentStateMessage } from '../../shared/components/empty-state'
import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
-import { deploymentDetailOverviewQueryAtom } from '../state'
-import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-status-section'
-import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-strip'
-import { ReleaseHero, ReleaseHeroSkeleton } from './release-hero'
+import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-summary/access-status-section'
+import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-status/environment-strip'
+import { ReleaseHero, ReleaseHeroSkeleton } from './release-summary/release-hero'
+import { deploymentOverviewQueryAtom } from './state'
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
@@ -61,9 +61,9 @@ function OverviewLoadingSkeleton() {
)
}
-export function OverviewTab() {
+export function DeploymentOverview() {
const { t } = useTranslation('deployments')
- const overviewQuery = useAtomValue(deploymentDetailOverviewQueryAtom)
+ const overviewQuery = useAtomValue(deploymentOverviewQueryAtom)
const overview = overviewQuery.data
if (overviewQuery.isLoading)
diff --git a/web/features/deployments/detail/overview/release-summary/README.md b/web/features/deployments/detail/overview/release-summary/README.md
new file mode 100644
index 00000000000..10ca6d364d1
--- /dev/null
+++ b/web/features/deployments/detail/overview/release-summary/README.md
@@ -0,0 +1,16 @@
+# Deployment Overview Release Summary
+
+Release summary section for showing the latest release and release creation entry point on the overview route.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| ----------------- | ------------------------------------------------------------------------------ |
+| `create-release` | Opens release creation from the release summary card. |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `route-state` | Reads the route app instance identity for release creation. |
+| `detail/overview` | Reuses overview card and icon styling shared by overview sections. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/overview-tab/release-hero.tsx b/web/features/deployments/detail/overview/release-summary/release-hero.tsx
similarity index 90%
rename from web/features/deployments/detail/overview-tab/release-hero.tsx
rename to web/features/deployments/detail/overview/release-summary/release-hero.tsx
index c39887d7b90..97751a07876 100644
--- a/web/features/deployments/detail/overview-tab/release-hero.tsx
+++ b/web/features/deployments/detail/overview/release-summary/release-hero.tsx
@@ -11,12 +11,11 @@ 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 { DeploymentEmptyState } from '../../components/empty-state'
-import { TitleTooltip } from '../../components/title-tooltip'
-import { CreateReleaseControl } from '../../create-release'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { formatDate, releaseCommit } from '../../shared/domain/release'
-import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
+import { CreateReleaseControl } from '../../../create-release'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+import { DeploymentEmptyState } from '../../../shared/components/empty-state'
+import { TitleTooltip } from '../../../shared/components/title-tooltip'
+import { formatDate, releaseCommit } from '../../../shared/domain/release'
type ReleaseHeroProps = {
latestRelease?: Release
@@ -29,6 +28,9 @@ type ReleaseMetaItemProps = {
children: ReactNode
}
+const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4'
+const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary'
+
export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
diff --git a/web/features/deployments/detail/overview/state.ts b/web/features/deployments/detail/overview/state.ts
new file mode 100644
index 00000000000..c9b1c9b7bc9
--- /dev/null
+++ b/web/features/deployments/detail/overview/state.ts
@@ -0,0 +1,19 @@
+'use client'
+
+import { skipToken } from '@tanstack/react-query'
+import { atomWithQuery } from 'jotai-tanstack-query'
+import { consoleQuery } from '@/service/client'
+import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
+
+export const deploymentOverviewQueryAtom = atomWithQuery((get) => {
+ const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
+
+ return consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
+ input: appInstanceId
+ ? {
+ params: { appInstanceId },
+ }
+ : skipToken,
+ enabled: Boolean(appInstanceId),
+ })
+})
diff --git a/web/features/deployments/detail/releases/README.md b/web/features/deployments/detail/releases/README.md
new file mode 100644
index 00000000000..72d05992f78
--- /dev/null
+++ b/web/features/deployments/detail/releases/README.md
@@ -0,0 +1,14 @@
+# Deployment Releases
+
+Releases route for release history, release actions, deployment summaries, and release DSL export.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| --------------------------------- | ---------------------------------------------------------- |
+| `route-state` | Reads the route app instance identity for release queries. |
+| `detail/releases/release-history` | Renders release history rows and pagination. |
+
+## External Modules
+
+None.
diff --git a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts b/web/features/deployments/detail/releases/__tests__/state.spec.ts
similarity index 56%
rename from web/features/deployments/detail/versions-tab/__tests__/state.spec.ts
rename to web/features/deployments/detail/releases/__tests__/state.spec.ts
index 2e58fb3bb80..0a2c0dbcdcf 100644
--- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts
+++ b/web/features/deployments/detail/releases/__tests__/state.spec.ts
@@ -25,22 +25,6 @@ vi.mock('jotai-tanstack-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
- appInstanceService: {
- getAppInstance: {
- queryOptions: (options: QueryOptions) => ({
- ...options,
- queryKey: ['getAppInstance', options.input],
- }),
- },
- },
- deploymentService: {
- listEnvironmentDeployments: {
- queryOptions: (options: QueryOptions) => ({
- ...options,
- queryKey: ['listEnvironmentDeployments', options.input],
- }),
- },
- },
releaseService: {
listReleaseSummaries: {
queryOptions: (options: QueryOptions) => ({
@@ -64,8 +48,8 @@ function setDeploymentRoute(store: ReturnType, appInstanceId
})
}
-describe('versions tab state', () => {
- it('should gate release history and menu queries until route and menu state are ready', async () => {
+describe('deployment releases state', () => {
+ it('should gate release history query until route state is ready', async () => {
const state = await loadState()
const store = createStore()
@@ -73,14 +57,6 @@ describe('versions tab state', () => {
enabled: false,
input: skipToken,
})
- expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({
- enabled: false,
- input: skipToken,
- })
- expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({
- enabled: false,
- input: skipToken,
- })
})
it('should build release history input from the current page', async () => {
@@ -103,39 +79,6 @@ describe('versions tab state', () => {
})
})
- it('should scope deploy menu queries to the open release id', async () => {
- const state = await loadState()
- const store = createStore()
- setDeploymentRoute(store)
-
- store.set(state.setDeployReleaseMenuOpenAtom, {
- releaseId: 'release-1',
- open: true,
- })
-
- expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBe('release-1')
- expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({
- enabled: true,
- input: { params: { appInstanceId: 'app-instance-1' } },
- })
- expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({
- enabled: true,
- input: { params: { appInstanceId: 'app-instance-1' } },
- })
-
- store.set(state.setDeployReleaseMenuOpenAtom, {
- releaseId: 'release-2',
- open: false,
- })
- expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBe('release-1')
-
- store.set(state.setDeployReleaseMenuOpenAtom, {
- releaseId: 'release-1',
- open: false,
- })
- expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBeUndefined()
- })
-
it('should adjust the release page only when deleting the last row on a later page', async () => {
const state = await loadState()
const store = createStore()
diff --git a/web/features/deployments/detail/releases/index.tsx b/web/features/deployments/detail/releases/index.tsx
new file mode 100644
index 00000000000..55e44b84eef
--- /dev/null
+++ b/web/features/deployments/detail/releases/index.tsx
@@ -0,0 +1,19 @@
+'use client'
+
+import { useAtomValue } from 'jotai'
+import { ScopeProvider } from 'jotai-scope'
+import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
+import { ReleaseHistoryTable } from './release-history/release-history-table'
+import { releasesLocalAtoms } from './state'
+
+export function DeploymentReleases() {
+ const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/web/features/deployments/detail/releases/release-actions/README.md b/web/features/deployments/detail/releases/release-actions/README.md
new file mode 100644
index 00000000000..1c82270dbed
--- /dev/null
+++ b/web/features/deployments/detail/releases/release-actions/README.md
@@ -0,0 +1,17 @@
+# Deployment Releases Actions
+
+Release action menu and dialogs for deploying, editing, deleting, and exporting releases.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| --------------- | ------------------------------------------------------------------------------ |
+| `deploy-drawer` | Opens the deploy drawer from release actions. |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `route-state` | Reads the route app instance identity for action queries and mutations. |
+
+## External Modules
+
+| Module | Why this module uses it |
+| ---------------- | --------------------------------------- |
+| `utils/download` | Downloads exported release DSL content. |
diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.spec.ts b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu-utils.spec.ts
similarity index 96%
rename from web/features/deployments/detail/versions-tab/deploy-release-menu-utils.spec.ts
rename to web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu-utils.spec.ts
index 0f5073bb102..4f2e394b2e6 100644
--- a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.spec.ts
+++ b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu-utils.spec.ts
@@ -6,7 +6,7 @@ import type {
import type { TFunction } from 'i18next'
import { ReleaseSource, RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
-import { buildDeployMenuSections } from './deploy-release-menu-utils'
+import { buildDeployMenuSections } from '../deploy-release-menu-utils'
const t = ((key: string, options?: { name?: string }) => {
return options?.name ? `${key}:${options.name}` : key
diff --git a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx
similarity index 90%
rename from web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx
rename to web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx
index 8fa353fbbf1..aa22da2ed76 100644
--- a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx
+++ b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx
@@ -50,9 +50,16 @@ vi.mock('../edit-release-dialog', () => ({
EditReleaseDialog: () => null,
}))
-vi.mock('../delete-release-dialog', () => ({
- DeleteReleaseDialog: ({ open }: { open: boolean }) => open ? delete confirm
: null,
-}))
+vi.mock('../delete-release-dialog', async () => {
+ const { useAtomValue } = await import('jotai')
+ const { deleteReleaseDialogOpenAtom } = await import('../state')
+
+ return {
+ DeleteReleaseDialog: () => useAtomValue(deleteReleaseDialogOpenAtom)
+ ? delete confirm
+ : null,
+ }
+})
vi.mock('../release-dsl-export', () => ({
exportReleaseDsl: vi.fn(),
diff --git a/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts b/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts
new file mode 100644
index 00000000000..5424bd62cd7
--- /dev/null
+++ b/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts
@@ -0,0 +1,109 @@
+import type { Getter } from 'jotai'
+import { skipToken } from '@tanstack/react-query'
+import { atom, createStore } from 'jotai'
+import { describe, expect, it, vi } from 'vitest'
+import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
+
+type QueryOptions = {
+ enabled?: boolean
+ input?: unknown
+ queryKey?: readonly unknown[]
+}
+
+vi.mock('jotai-tanstack-query', () => ({
+ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
+ ...createOptions(get),
+ data: undefined,
+ isError: false,
+ isFetching: false,
+ isLoading: false,
+ isSuccess: false,
+ })),
+}))
+
+vi.mock('@/service/client', () => ({
+ consoleQuery: {
+ enterprise: {
+ appInstanceService: {
+ getAppInstance: {
+ queryOptions: (options: QueryOptions) => ({
+ ...options,
+ queryKey: ['getAppInstance', options.input],
+ }),
+ },
+ },
+ deploymentService: {
+ listEnvironmentDeployments: {
+ queryOptions: (options: QueryOptions) => ({
+ ...options,
+ queryKey: ['listEnvironmentDeployments', options.input],
+ }),
+ },
+ },
+ },
+ },
+}))
+
+async function loadState() {
+ return await import('../state')
+}
+
+function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') {
+ store.set(setNextRouteStateAtom, {
+ pathname: `/deployments/${appInstanceId}/releases`,
+ params: { appInstanceId },
+ })
+}
+
+describe('deployment release actions state', () => {
+ it('should gate action queries until route and menu state are ready', async () => {
+ const state = await loadState()
+ const store = createStore()
+
+ expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({
+ enabled: false,
+ input: skipToken,
+ })
+ expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({
+ enabled: false,
+ input: skipToken,
+ })
+
+ setDeploymentRoute(store)
+
+ expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({
+ enabled: false,
+ input: { params: { appInstanceId: 'app-instance-1' } },
+ })
+
+ store.set(state.deployReleaseMenuOpenAtom, true)
+
+ expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({
+ enabled: true,
+ input: { params: { appInstanceId: 'app-instance-1' } },
+ })
+ expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({
+ enabled: true,
+ input: { params: { appInstanceId: 'app-instance-1' } },
+ })
+ })
+
+ it('should open one secondary dialog at a time and close the menu', async () => {
+ const state = await loadState()
+ const store = createStore()
+
+ store.set(state.deployReleaseMenuOpenAtom, true)
+ store.set(state.openDeleteReleaseDialogAtom)
+
+ expect(store.get(state.deployReleaseMenuOpenAtom)).toBe(false)
+ expect(store.get(state.editReleaseDialogOpenAtom)).toBe(false)
+ expect(store.get(state.deleteReleaseDialogOpenAtom)).toBe(true)
+
+ store.set(state.deployReleaseMenuOpenAtom, true)
+ store.set(state.openEditReleaseDialogAtom)
+
+ expect(store.get(state.deployReleaseMenuOpenAtom)).toBe(false)
+ expect(store.get(state.editReleaseDialogOpenAtom)).toBe(true)
+ expect(store.get(state.deleteReleaseDialogOpenAtom)).toBe(false)
+ })
+})
diff --git a/web/features/deployments/detail/versions-tab/delete-release-dialog.tsx b/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx
similarity index 79%
rename from web/features/deployments/detail/versions-tab/delete-release-dialog.tsx
rename to web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx
index 5e60685f73c..f7d4ad19e0e 100644
--- a/web/features/deployments/detail/versions-tab/delete-release-dialog.tsx
+++ b/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx
@@ -1,6 +1,5 @@
'use client'
-import type { Release } from '@dify/contracts/enterprise/types.gen'
import {
AlertDialog,
AlertDialogActions,
@@ -10,22 +9,26 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
+import { useAtom, useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
+import {
+ deleteReleaseDialogOpenAtom,
+ releaseActionItemAtom,
+} from './state'
export function DeleteReleaseDialog({
- open,
- release,
isDeleting,
- onOpenChange,
onConfirm,
}: {
- open: boolean
- release: Release
isDeleting: boolean
- onOpenChange: (open: boolean) => void
onConfirm: () => void
}) {
const { t } = useTranslation('deployments')
+ const { releaseId, releaseRows } = useAtomValue(releaseActionItemAtom)
+ const [open, setOpen] = useAtom(deleteReleaseDialogOpenAtom)
+ const release = releaseRows.find(release => release.id === releaseId)
+ if (!release)
+ return null
return (
{
if (isDeleting)
return
- onOpenChange(nextOpen)
+ setOpen(nextOpen)
}}
>
diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.ts b/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts
similarity index 96%
rename from web/features/deployments/detail/versions-tab/deploy-release-menu-utils.ts
rename to web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts
index 9498aab0024..df3b5772812 100644
--- a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.ts
+++ b/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts
@@ -4,8 +4,8 @@ import type {
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { TFunction } from 'i18next'
-import { releaseDeploymentAction } from '../../shared/domain/release-action'
-import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
+import { releaseDeploymentAction } from '../../../shared/domain/release-action'
+import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status'
export type DeployMenuRowState = 'deploy' | 'rollback' | 'current' | 'deploying'
diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx
similarity index 82%
rename from web/features/deployments/detail/versions-tab/deploy-release-menu.tsx
rename to web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx
index 8f97d27533f..ce97ff261b8 100644
--- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx
+++ b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx
@@ -10,15 +10,15 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { mutationOptions, useMutation } from '@tanstack/react-query'
-import { useAtomValue, useSetAtom } from 'jotai'
-import { useState } from 'react'
+import { useAtom, useAtomValue, useSetAtom } from 'jotai'
+import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
-import { TitleTooltip } from '../../components/title-tooltip'
-import { openDeployDrawerAtom } from '../../deploy-drawer/state'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
-import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
+import { openDeployDrawerAtom } from '../../../deploy-drawer/state'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../../../shared/components/detail-table-styles'
+import { TitleTooltip } from '../../../shared/components/title-tooltip'
+import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status'
import { DeleteReleaseDialog } from './delete-release-dialog'
import {
buildDeployMenuSections,
@@ -27,10 +27,14 @@ import {
import { EditReleaseDialog } from './edit-release-dialog'
import { exportReleaseDsl } from './release-dsl-export'
import {
+ deleteReleaseDialogOpenAtom,
deployReleaseMenuAppInstanceQueryAtom,
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
- deployReleaseMenuOpenReleaseIdAtom,
- setDeployReleaseMenuOpenAtom,
+ deployReleaseMenuOpenAtom,
+ openDeleteReleaseDialogAtom,
+ openEditReleaseDialogAtom,
+ releaseActionItemAtom,
+ releaseActionLocalAtoms,
} from './state'
type ExportReleaseDslInput = {
@@ -39,19 +43,17 @@ type ExportReleaseDslInput = {
appInstanceName?: string
}
-export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
- releaseId: string
- releaseRows: Release[]
+function DeployReleaseMenuContent({ onDeleted }: {
onDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
- const openReleaseMenuId = useAtomValue(deployReleaseMenuOpenReleaseIdAtom)
- const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom)
- const [showEditDialog, setShowEditDialog] = useState(false)
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
- const open = openReleaseMenuId === releaseId
+ const { releaseId, releaseRows } = useAtomValue(releaseActionItemAtom)
+ const [open, setOpen] = useAtom(deployReleaseMenuOpenAtom)
+ const setDeleteReleaseDialogOpen = useSetAtom(deleteReleaseDialogOpenAtom)
+ const openEditReleaseDialog = useSetAtom(openEditReleaseDialogAtom)
+ const openDeleteReleaseDialog = useSetAtom(openDeleteReleaseDialogAtom)
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom)
const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions())
@@ -86,10 +88,6 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
: undefined
const deleteActionDisabled = isDeletingRelease || isCheckingDeleteUsage || hasDeleteUsageCheckFailed || isReleaseInUse
- function handleOpenChange(nextOpen: boolean) {
- setDeployReleaseMenuOpen({ releaseId, open: nextOpen })
- }
-
function handleExportDsl() {
if (isExportingDsl)
return
@@ -98,7 +96,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
{ release, releaseId, appInstanceName },
{
onSuccess: () => {
- handleOpenChange(false)
+ setOpen(false)
},
onError: () => {
toast.error(t('versions.exportDslFailed'))
@@ -119,7 +117,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
},
{
onSuccess: () => {
- setShowDeleteConfirm(false)
+ setDeleteReleaseDialogOpen(false)
toast.success(t('versions.deleteSuccess', { name: targetReleaseName }))
onDeleted?.()
},
@@ -141,7 +139,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
return (
<>
-
+
{
- handleOpenChange(false)
- setShowEditDialog(true)
- }}
+ onClick={openEditReleaseDialog}
>
@@ -197,7 +192,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
onClick={() => {
if (isDisabled || !appInstanceId)
return
- handleOpenChange(false)
+ setOpen(false)
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
}}
>
@@ -223,8 +218,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
onClick={() => {
if (deleteActionDisabled)
return
- handleOpenChange(false)
- setShowDeleteConfirm(true)
+ openDeleteReleaseDialog()
}}
>
@@ -235,19 +229,31 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
)}
-
+
>
)
}
+
+export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
+ releaseId: string
+ releaseRows: Release[]
+ onDeleted?: () => void
+}) {
+ return (
+
+
+
+ )
+}
diff --git a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx b/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx
similarity index 59%
rename from web/features/deployments/detail/versions-tab/edit-release-dialog.tsx
rename to web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx
index 28ca0b6ff1d..107fa4e78ea 100644
--- a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx
+++ b/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx
@@ -1,7 +1,6 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
-import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@@ -10,19 +9,43 @@ import {
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
-import { Input } from '@langgenius/dify-ui/input'
+import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
+import { Form } from '@langgenius/dify-ui/form'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
-import { useState } from 'react'
+import { useAtom, useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
+import {
+ editReleaseDialogOpenAtom,
+ releaseActionItemAtom,
+} from './state'
type EditReleaseFormValues = {
name: string
description: string
}
+function normalizedEditReleaseFormValues(value: EditReleaseFormValues) {
+ return {
+ name: value.name.trim(),
+ description: value.description.trim(),
+ }
+}
+
+function canSubmitEditReleaseForm(initialValues: EditReleaseFormValues, value: EditReleaseFormValues) {
+ const normalizedValues = normalizedEditReleaseFormValues(value)
+
+ return Boolean(
+ normalizedValues.name
+ && (
+ normalizedValues.name !== initialValues.name
+ || normalizedValues.description !== initialValues.description
+ ),
+ )
+}
+
function EditReleaseForm({
release,
isSaving,
@@ -35,66 +58,47 @@ function EditReleaseForm({
onSubmit: (values: EditReleaseFormValues) => void
}) {
const { t } = useTranslation('deployments')
- const initialName = release.displayName
- const initialDescription = release.description
- const [name, setName] = useState(initialName)
- const [description, setDescription] = useState(initialDescription)
- const normalizedName = name.trim()
- const normalizedDescription = description.trim()
- const nameRequired = !normalizedName
- const hasChanges = normalizedName !== initialName || normalizedDescription !== initialDescription
- const canSave = Boolean(!nameRequired && hasChanges && !isSaving)
+ const nameLabel = t('versions.releaseNameLabel')
+ const initialValues = {
+ name: release.displayName,
+ description: release.description,
+ }
- function handleSubmit(event: FormEvent) {
- event.preventDefault()
- if (!canSave)
+ function handleSubmit(values: EditReleaseFormValues) {
+ if (!canSubmitEditReleaseForm(initialValues, values))
return
- onSubmit({
- name: normalizedName,
- description: normalizedDescription,
- })
+ onSubmit(normalizedEditReleaseFormValues(values))
}
return (
-
+
)
}
-export function EditReleaseDialog({
- release,
- open,
- onOpenChange,
-}: {
- release: Release
- open: boolean
- onOpenChange: (open: boolean) => void
-}) {
+export function EditReleaseDialog() {
const { t } = useTranslation('deployments')
+ const { releaseId, releaseRows } = useAtomValue(releaseActionItemAtom)
+ const [open, setOpen] = useAtom(editReleaseDialogOpenAtom)
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
+ const targetRelease = releaseRows.find(release => release.id === releaseId)
+ if (!targetRelease)
+ return null
+
+ const release = targetRelease
const formKey = `${release.id}-${release.displayName}-${release.description}`
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && updateRelease.isPending)
return
- onOpenChange(nextOpen)
+ setOpen(nextOpen)
}
function handleSubmit(values: EditReleaseFormValues) {
diff --git a/web/features/deployments/detail/versions-tab/release-dsl-export.ts b/web/features/deployments/detail/releases/release-actions/release-dsl-export.ts
similarity index 100%
rename from web/features/deployments/detail/versions-tab/release-dsl-export.ts
rename to web/features/deployments/detail/releases/release-actions/release-dsl-export.ts
diff --git a/web/features/deployments/detail/versions-tab/release-dsl.ts b/web/features/deployments/detail/releases/release-actions/release-dsl.ts
similarity index 100%
rename from web/features/deployments/detail/versions-tab/release-dsl.ts
rename to web/features/deployments/detail/releases/release-actions/release-dsl.ts
diff --git a/web/features/deployments/detail/releases/release-actions/state.ts b/web/features/deployments/detail/releases/release-actions/state.ts
new file mode 100644
index 00000000000..de9630800f9
--- /dev/null
+++ b/web/features/deployments/detail/releases/release-actions/state.ts
@@ -0,0 +1,68 @@
+'use client'
+
+import type { Release } from '@dify/contracts/enterprise/types.gen'
+import { skipToken } from '@tanstack/react-query'
+import { atom } from 'jotai'
+import { atomWithQuery } from 'jotai-tanstack-query'
+import { atomWithLazy } from 'jotai/utils'
+import { consoleQuery } from '@/service/client'
+import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
+
+export type ReleaseActionItem = {
+ releaseId: string
+ releaseRows: Release[]
+}
+
+export const releaseActionItemAtom = atomWithLazy(() => {
+ throw new Error('Missing release action item.')
+})
+
+export const deployReleaseMenuOpenAtom = atom(false)
+export const editReleaseDialogOpenAtom = atom(false)
+export const deleteReleaseDialogOpenAtom = atom(false)
+
+export const deployReleaseMenuEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => {
+ const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
+ const menuOpen = get(deployReleaseMenuOpenAtom)
+
+ return consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
+ input: appInstanceId
+ ? {
+ params: { appInstanceId },
+ }
+ : skipToken,
+ enabled: Boolean(appInstanceId && menuOpen),
+ })
+})
+
+export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
+ const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
+ const menuOpen = get(deployReleaseMenuOpenAtom)
+
+ return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
+ input: appInstanceId
+ ? {
+ params: { appInstanceId },
+ }
+ : skipToken,
+ enabled: Boolean(appInstanceId && menuOpen),
+ })
+})
+
+export const openEditReleaseDialogAtom = atom(null, (_get, set) => {
+ set(deployReleaseMenuOpenAtom, false)
+ set(deleteReleaseDialogOpenAtom, false)
+ set(editReleaseDialogOpenAtom, true)
+})
+
+export const openDeleteReleaseDialogAtom = atom(null, (_get, set) => {
+ set(deployReleaseMenuOpenAtom, false)
+ set(editReleaseDialogOpenAtom, false)
+ set(deleteReleaseDialogOpenAtom, true)
+})
+
+export const releaseActionLocalAtoms = [
+ deployReleaseMenuOpenAtom,
+ editReleaseDialogOpenAtom,
+ deleteReleaseDialogOpenAtom,
+] as const
diff --git a/web/features/deployments/detail/releases/release-history/README.md b/web/features/deployments/detail/releases/release-history/README.md
new file mode 100644
index 00000000000..c89dbf53472
--- /dev/null
+++ b/web/features/deployments/detail/releases/release-history/README.md
@@ -0,0 +1,17 @@
+# Deployment Releases History
+
+Release history section for rendering release rows, deployment summaries, and pagination on the releases route.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| --------------------------------- | ------------------------------------------------------------------------------ |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `detail/releases` | Reads releases route query and pagination state. |
+| `detail/releases/release-actions` | Renders release action menus inside release history rows. |
+
+## External Modules
+
+| Module | Why this module uses it |
+| -------------------------------- | ------------------------------------------------------------ |
+| `hooks/use-format-time-from-now` | Formats relative release timestamps in release history rows. |
diff --git a/web/features/deployments/detail/versions-tab/__tests__/release-history-rows.spec.tsx b/web/features/deployments/detail/releases/release-history/__tests__/release-history-rows.spec.tsx
similarity index 98%
rename from web/features/deployments/detail/versions-tab/__tests__/release-history-rows.spec.tsx
rename to web/features/deployments/detail/releases/release-history/__tests__/release-history-rows.spec.tsx
index 5e618316c4e..2f352fc0943 100644
--- a/web/features/deployments/detail/versions-tab/__tests__/release-history-rows.spec.tsx
+++ b/web/features/deployments/detail/releases/release-history/__tests__/release-history-rows.spec.tsx
@@ -4,7 +4,7 @@ import { render, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ReleaseHistoryRows } from '../release-history-rows'
-vi.mock('../deploy-release-menu', () => ({
+vi.mock('../../release-actions/deploy-release-menu', () => ({
DeployReleaseMenu: () => ,
}))
diff --git a/web/features/deployments/detail/versions-tab/deployed-to-badge.tsx b/web/features/deployments/detail/releases/release-history/deployed-to-badge.tsx
similarity index 88%
rename from web/features/deployments/detail/versions-tab/deployed-to-badge.tsx
rename to web/features/deployments/detail/releases/release-history/deployed-to-badge.tsx
index b0159043b78..4ed014c77e2 100644
--- a/web/features/deployments/detail/versions-tab/deployed-to-badge.tsx
+++ b/web/features/deployments/detail/releases/release-history/deployed-to-badge.tsx
@@ -4,11 +4,11 @@ import type { ReleaseDeployment } from './release-deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
-import { isRuntimeDeploymentInProgress } from '../../shared/domain/runtime-status'
+import { isRuntimeDeploymentInProgress } from '../../../shared/domain/runtime-status'
import {
deploymentStatusDotStatus,
deploymentStatusDotTextClassName,
-} from '../../shared/ui/deployment-status-style'
+} from '../../../shared/ui/deployment-status-style'
export function DeployedToBadge({ item }: {
item: ReleaseDeployment
diff --git a/web/features/deployments/detail/versions-tab/release-deployments.ts b/web/features/deployments/detail/releases/release-history/release-deployments.ts
similarity index 100%
rename from web/features/deployments/detail/versions-tab/release-deployments.ts
rename to web/features/deployments/detail/releases/release-history/release-deployments.ts
diff --git a/web/features/deployments/detail/versions-tab/release-history-deployments.tsx b/web/features/deployments/detail/releases/release-history/release-history-deployments.tsx
similarity index 100%
rename from web/features/deployments/detail/versions-tab/release-history-deployments.tsx
rename to web/features/deployments/detail/releases/release-history/release-history-deployments.tsx
diff --git a/web/features/deployments/detail/versions-tab/release-history-rows.tsx b/web/features/deployments/detail/releases/release-history/release-history-rows.tsx
similarity index 96%
rename from web/features/deployments/detail/versions-tab/release-history-rows.tsx
rename to web/features/deployments/detail/releases/release-history/release-history-rows.tsx
index 32a66c4a26f..ffacee23b25 100644
--- a/web/features/deployments/detail/versions-tab/release-history-rows.tsx
+++ b/web/features/deployments/detail/releases/release-history/release-history-rows.tsx
@@ -9,11 +9,6 @@ import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
-import { TitleTooltip } from '../../components/title-tooltip'
-import {
- formatDate,
- releaseCommit,
-} from '../../shared/domain/release'
import {
DetailTable,
DetailTableBody,
@@ -23,12 +18,17 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
-} from '../components/detail-table'
-import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
-import { DeployReleaseMenu } from './deploy-release-menu'
+} from '../../../shared/components/detail-table'
+import { TitleTooltip } from '../../../shared/components/title-tooltip'
+import {
+ formatDate,
+ releaseCommit,
+} from '../../../shared/domain/release'
+import { DeployReleaseMenu } from '../release-actions/deploy-release-menu'
import {
ReleaseDeploymentsContent,
} from './release-history-deployments'
+import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from './table-styles'
function ReleaseTitleTooltip({ release }: {
release: Release
diff --git a/web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx b/web/features/deployments/detail/releases/release-history/release-history-table-skeleton.tsx
similarity index 97%
rename from web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx
rename to web/features/deployments/detail/releases/release-history/release-history-table-skeleton.tsx
index 40e7a6fca3e..0a34ee0f0ef 100644
--- a/web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx
+++ b/web/features/deployments/detail/releases/release-history/release-history-table-skeleton.tsx
@@ -11,8 +11,8 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
-} from '../components/detail-table'
-import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
+} from '../../../shared/components/detail-table'
+import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from './table-styles'
const RELEASE_TABLE_ROW_SKELETON_KEYS = ['latest', 'previous', 'older', 'archived', 'initial']
diff --git a/web/features/deployments/detail/versions-tab/release-history-table.tsx b/web/features/deployments/detail/releases/release-history/release-history-table.tsx
similarity index 95%
rename from web/features/deployments/detail/versions-tab/release-history-table.tsx
rename to web/features/deployments/detail/releases/release-history/release-history-table.tsx
index 8ef20e68432..07debb96df1 100644
--- a/web/features/deployments/detail/versions-tab/release-history-table.tsx
+++ b/web/features/deployments/detail/releases/release-history/release-history-table.tsx
@@ -3,17 +3,17 @@
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
-import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
-import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination'
-import { ReleaseHistoryRows } from './release-history-rows'
-import { ReleaseHistoryTableSkeleton } from './release-history-table-skeleton'
-import { releaseRowFromSummary } from './release-history-types'
+import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import {
adjustReleaseHistoryPageAfterDeleteAtom,
+ RELEASE_HISTORY_PAGE_SIZE,
releaseHistoryCurrentPageAtom,
releaseHistoryQueryAtom,
setReleaseHistoryCurrentPageAtom,
-} from './state'
+} from '../state'
+import { ReleaseHistoryRows } from './release-history-rows'
+import { ReleaseHistoryTableSkeleton } from './release-history-table-skeleton'
+import { releaseRowFromSummary } from './release-history-types'
export function ReleaseHistoryTable() {
const { t } = useTranslation('deployments')
diff --git a/web/features/deployments/detail/versions-tab/release-history-types.ts b/web/features/deployments/detail/releases/release-history/release-history-types.ts
similarity index 100%
rename from web/features/deployments/detail/versions-tab/release-history-types.ts
rename to web/features/deployments/detail/releases/release-history/release-history-types.ts
diff --git a/web/features/deployments/detail/releases/release-history/table-styles.ts b/web/features/deployments/detail/releases/release-history/table-styles.ts
new file mode 100644
index 00000000000..8ad11298a1e
--- /dev/null
+++ b/web/features/deployments/detail/releases/release-history/table-styles.ts
@@ -0,0 +1,8 @@
+export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
+ action: 'w-14 py-1.5 whitespace-nowrap',
+ author: 'whitespace-nowrap',
+ createdAt: 'whitespace-nowrap',
+ deployedTo: '',
+ release: 'whitespace-nowrap',
+ sourceApp: 'whitespace-nowrap',
+}
diff --git a/web/features/deployments/detail/releases/state.ts b/web/features/deployments/detail/releases/state.ts
new file mode 100644
index 00000000000..add79f6e2a0
--- /dev/null
+++ b/web/features/deployments/detail/releases/state.ts
@@ -0,0 +1,44 @@
+'use client'
+
+import { keepPreviousData, skipToken } from '@tanstack/react-query'
+import { atom } from 'jotai'
+import { atomWithQuery } from 'jotai-tanstack-query'
+import { consoleQuery } from '@/service/client'
+import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
+
+export const RELEASE_HISTORY_PAGE_SIZE = 20
+
+export const releaseHistoryCurrentPageAtom = atom(0)
+
+export const releaseHistoryQueryAtom = atomWithQuery((get) => {
+ const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
+ const currentPage = get(releaseHistoryCurrentPageAtom)
+
+ return consoleQuery.enterprise.releaseService.listReleaseSummaries.queryOptions({
+ input: appInstanceId
+ ? {
+ params: { appInstanceId },
+ query: {
+ pageNumber: currentPage + 1,
+ resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
+ },
+ }
+ : skipToken,
+ enabled: Boolean(appInstanceId),
+ placeholderData: keepPreviousData,
+ })
+})
+
+export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => {
+ set(releaseHistoryCurrentPageAtom, Math.max(page, 0))
+})
+
+export const adjustReleaseHistoryPageAfterDeleteAtom = atom(null, (get, set, remainingRowsOnPage: number) => {
+ const currentPage = get(releaseHistoryCurrentPageAtom)
+ if (remainingRowsOnPage === 1 && currentPage > 0)
+ set(releaseHistoryCurrentPageAtom, currentPage - 1)
+})
+
+export const releasesLocalAtoms = [
+ releaseHistoryCurrentPageAtom,
+] as const
diff --git a/web/features/deployments/detail/state.ts b/web/features/deployments/detail/state.ts
index f604ac7d214..6356964f797 100644
--- a/web/features/deployments/detail/state.ts
+++ b/web/features/deployments/detail/state.ts
@@ -1,10 +1,26 @@
'use client'
import { skipToken } from '@tanstack/react-query'
+import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
+import { nextPathnameAtom } from '@/app/components/next-route-state/atoms'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
-import { deploymentStatusPollingInterval } from '../shared/domain/runtime-status'
+import {
+ deploymentStatusPollingInterval,
+ hasRuntimeInstanceDeployment,
+} from '../shared/domain/runtime-status'
+import { isInstanceDetailTabKey } from './tabs'
+
+export const deploymentDetailActiveTabAtom = atom((get) => {
+ const pathnameSegments = get(nextPathnameAtom).split('/').filter(Boolean)
+ const deploymentsSegmentIndex = pathnameSegments.indexOf('deployments')
+ const selectedSegment = deploymentsSegmentIndex >= 0
+ ? pathnameSegments[deploymentsSegmentIndex + 2]
+ : undefined
+
+ return isInstanceDetailTabKey(selectedSegment) ? selectedSegment : 'overview'
+})
export const deploymentDetailAppInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
@@ -19,19 +35,6 @@ export const deploymentDetailAppInstanceQueryAtom = atomWithQuery((get) => {
})
})
-export const deploymentDetailOverviewQueryAtom = atomWithQuery((get) => {
- const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
-
- return consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
- input: appInstanceId
- ? {
- params: { appInstanceId },
- }
- : skipToken,
- enabled: Boolean(appInstanceId),
- })
-})
-
export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
@@ -45,3 +48,7 @@ export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) =>
refetchInterval: query => deploymentStatusPollingInterval(query.state.data?.environmentDeployments),
})
})
+
+export const deploymentRuntimeInstanceRowsAtom = atom((get) => {
+ return get(deploymentEnvironmentDeploymentsQueryAtom).data?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
+})
diff --git a/web/features/deployments/detail/versions-tab/index.tsx b/web/features/deployments/detail/versions-tab/index.tsx
deleted file mode 100644
index 0f70ce6b47e..00000000000
--- a/web/features/deployments/detail/versions-tab/index.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-'use client'
-import { ReleaseHistoryTable } from './release-history-table'
-
-export function VersionsTab() {
- return (
-
-
-
- )
-}
diff --git a/web/features/deployments/detail/versions-tab/state.ts b/web/features/deployments/detail/versions-tab/state.ts
deleted file mode 100644
index 4d90228a3af..00000000000
--- a/web/features/deployments/detail/versions-tab/state.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-'use client'
-
-import { keepPreviousData, skipToken } from '@tanstack/react-query'
-import { atom } from 'jotai'
-import { atomWithQuery } from 'jotai-tanstack-query'
-import { consoleQuery } from '@/service/client'
-import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
-import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination'
-
-export const releaseHistoryCurrentPageAtom = atom(0)
-export const deployReleaseMenuOpenReleaseIdAtom = atom(undefined)
-
-export const releaseHistoryQueryAtom = atomWithQuery((get) => {
- const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
- const currentPage = get(releaseHistoryCurrentPageAtom)
-
- return consoleQuery.enterprise.releaseService.listReleaseSummaries.queryOptions({
- input: appInstanceId
- ? {
- params: { appInstanceId },
- query: {
- pageNumber: currentPage + 1,
- resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
- },
- }
- : skipToken,
- enabled: Boolean(appInstanceId),
- placeholderData: keepPreviousData,
- })
-})
-
-export const deployReleaseMenuEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => {
- const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
- const openReleaseId = get(deployReleaseMenuOpenReleaseIdAtom)
-
- return consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
- input: appInstanceId
- ? {
- params: { appInstanceId },
- }
- : skipToken,
- enabled: Boolean(appInstanceId && openReleaseId),
- })
-})
-
-export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
- const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
- const openReleaseId = get(deployReleaseMenuOpenReleaseIdAtom)
-
- return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
- input: appInstanceId
- ? {
- params: { appInstanceId },
- }
- : skipToken,
- enabled: Boolean(appInstanceId && openReleaseId),
- })
-})
-
-export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => {
- set(releaseHistoryCurrentPageAtom, Math.max(page, 0))
-})
-
-export const adjustReleaseHistoryPageAfterDeleteAtom = atom(null, (get, set, remainingRowsOnPage: number) => {
- const currentPage = get(releaseHistoryCurrentPageAtom)
- if (remainingRowsOnPage === 1 && currentPage > 0)
- set(releaseHistoryCurrentPageAtom, currentPage - 1)
-})
-
-export const setDeployReleaseMenuOpenAtom = atom(null, (get, set, {
- releaseId,
- open,
-}: {
- releaseId: string
- open: boolean
-}) => {
- if (open) {
- set(deployReleaseMenuOpenReleaseIdAtom, releaseId)
- return
- }
-
- if (get(deployReleaseMenuOpenReleaseIdAtom) === releaseId)
- set(deployReleaseMenuOpenReleaseIdAtom, undefined)
-})
-
-export const versionsTabLocalAtoms = [
- releaseHistoryCurrentPageAtom,
- deployReleaseMenuOpenReleaseIdAtom,
-] as const
diff --git a/web/features/deployments/list/README.md b/web/features/deployments/list/README.md
new file mode 100644
index 00000000000..ec04502db93
--- /dev/null
+++ b/web/features/deployments/list/README.md
@@ -0,0 +1,21 @@
+# Deployments List
+
+Deployment list page for browsing app instances, filtering by keyword or environment, and rendering deployment status cards.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| --------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| `create-guide/link` | Links the create deployment CTA to the create-guide workflow while keeping list-owned button styling local. |
+| `deployment-actions` | Reuses the deployment action menu for each card. The list supplies the app instance identity and placement only. |
+| `create-release` | Reuses release creation entry points from card footer actions. |
+| `deploy-drawer/state` | Opens the deploy drawer from a card action without making the list own deploy form state. |
+| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
+| `detail/tabs` | Uses the detail tab key type when building card navigation URLs. The list must not depend on detail tab components. |
+
+## External Modules
+
+| Module | Why this module uses it |
+| ---------------------------------------- | ----------------------------------------------------------------------------------------- |
+| `app/components/apps/studio-list-header` | Reuses the app studio list header layout instead of defining a list-specific page chrome. |
+| `app/components/base/skeleton` | Reuses the existing skeleton primitive for list and card loading states. |
diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts
index 2e24efb7ef6..f33a2525640 100644
--- a/web/features/deployments/list/state/index.ts
+++ b/web/features/deployments/list/state/index.ts
@@ -3,13 +3,14 @@
import type { ReactNode } from 'react'
import { keepPreviousData } from '@tanstack/react-query'
import { atom } from 'jotai'
-import { atomWithInfiniteQuery } from 'jotai-tanstack-query'
+import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/utils'
import { parseAsString, useQueryState } from 'nuqs'
import { consoleQuery } from '@/service/client'
-import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../../shared/domain/pagination'
import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status'
+const DEPLOYMENTS_LIST_PAGE_SIZE = 100
+
export const envFilterQueryState = parseAsString.withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })
@@ -18,6 +19,12 @@ export const keywordsQueryState = parseAsString.withDefault('').withOptions({ hi
const deploymentsListKeywordsAtom = atom('')
const deploymentsListEnvironmentIdAtom = atom(null)
+export type DeploymentsListEnvironmentFilterOption = {
+ kind: 'all' | 'environment'
+ value: string | null
+ displayName?: string
+}
+
export function DeploymentsListStateBoundary({ children }: {
children: ReactNode
}) {
@@ -32,6 +39,50 @@ export function DeploymentsListStateBoundary({ children }: {
return children
}
+const deploymentsListEnvironmentsQueryAtom = atomWithQuery(() => {
+ return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
+ input: {
+ query: {
+ // The filter lists every deployable environment; environment count is
+ // capped well below the 100-per-page maximum.
+ pageNumber: 1,
+ resultsPerPage: 100,
+ },
+ },
+ })
+})
+
+export const deploymentsListEnvironmentFilterOptionsAtom = atom((get): DeploymentsListEnvironmentFilterOption[] => {
+ const environments = get(deploymentsListEnvironmentsQueryAtom).data?.environments ?? []
+
+ return [
+ {
+ kind: 'all',
+ value: null,
+ },
+ ...environments.map(environment => ({
+ kind: 'environment' as const,
+ value: environment.id,
+ displayName: environment.displayName,
+ })),
+ ]
+})
+
+export const deploymentsListSelectedEnvironmentFilterOptionAtom = atom((get): DeploymentsListEnvironmentFilterOption => {
+ const envFilter = get(deploymentsListEnvironmentIdAtom)
+ const options = get(deploymentsListEnvironmentFilterOptionsAtom)
+ const allOption = options[0] ?? { kind: 'all' as const, value: null }
+
+ return options.find(option => option.value === envFilter)
+ ?? (envFilter
+ ? {
+ kind: 'environment',
+ value: envFilter,
+ displayName: envFilter,
+ }
+ : allOption)
+})
+
export const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
const queryKeywords = get(deploymentsListKeywordsAtom).trim()
const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined
@@ -40,12 +91,17 @@ export const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
- resultsPerPage: SOURCE_APPS_PAGE_SIZE,
+ resultsPerPage: DEPLOYMENTS_LIST_PAGE_SIZE,
...(queryEnvironmentId ? { environmentId: queryEnvironmentId } : {}),
...(queryKeywords ? { displayName: queryKeywords } : {}),
},
}),
- getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
+ getNextPageParam: (lastPage) => {
+ const currentPage = lastPage.pagination?.currentPage ?? 1
+ const totalPages = lastPage.pagination?.totalPages ?? 1
+
+ return currentPage < totalPages ? currentPage + 1 : undefined
+ },
initialPageParam: 1,
placeholderData: keepPreviousData,
refetchInterval: (query) => {
@@ -75,6 +131,11 @@ export const deploymentsListShowEmptyStateAtom = atom((get) => {
&& get(deploymentsListRowsAtom).length === 0
})
+export const deploymentsListShowErrorStateAtom = atom((get) => {
+ return !get(deploymentsListShowSkeletonAtom)
+ && get(deploymentsListQueryAtom).isError
+})
+
export const deploymentsListHasFilterAtom = atom((get) => {
return Boolean(get(deploymentsListKeywordsAtom).trim() || get(deploymentsListEnvironmentIdAtom))
})
diff --git a/web/features/deployments/list/ui/create-deployment-button.tsx b/web/features/deployments/list/ui/create-deployment-button.tsx
index 75918bfe445..1db10418627 100644
--- a/web/features/deployments/list/ui/create-deployment-button.tsx
+++ b/web/features/deployments/list/ui/create-deployment-button.tsx
@@ -2,7 +2,7 @@
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
-import Link from '@/next/link'
+import { CreateDeploymentGuideLink } from '../../create-guide/link'
export function CreateDeploymentButton({ className }: {
className?: string
@@ -10,8 +10,7 @@ export function CreateDeploymentButton({ className }: {
const { t } = useTranslation('deployments')
return (
-
{t('list.createDeployment')}
-
+
)
}
diff --git a/web/features/deployments/list/ui/environment-filter.tsx b/web/features/deployments/list/ui/environment-filter.tsx
index d99f90d39df..5a35dcf190f 100644
--- a/web/features/deployments/list/ui/environment-filter.tsx
+++ b/web/features/deployments/list/ui/environment-filter.tsx
@@ -1,6 +1,6 @@
'use client'
-import type { ReactNode } from 'react'
+import type { DeploymentsListEnvironmentFilterOption } from '../state'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@@ -8,62 +8,41 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
-import { useQuery } from '@tanstack/react-query'
+import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { consoleQuery } from '@/service/client'
-import { envFilterQueryState } from '../state'
-
-type EnvironmentFilterOption = {
- value: string | null
- text: string
- icon: ReactNode
-}
+import {
+ deploymentsListEnvironmentFilterOptionsAtom,
+ deploymentsListSelectedEnvironmentFilterOptionAtom,
+ envFilterQueryState,
+} from '../state'
function EnvironmentOptionIcon() {
return
}
+function EnvironmentFilterOptionIcon({ option }: {
+ option: DeploymentsListEnvironmentFilterOption
+}) {
+ return option.kind === 'all'
+ ?
+ :
+}
+
export function EnvironmentFilter({ className }: {
className?: string
}) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
- const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
- const environmentsQuery = useQuery(consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
- input: {
- query: {
- // The filter lists every deployable environment; environment count is
- // capped well below the 100-per-page maximum.
- pageNumber: 1,
- resultsPerPage: 100,
- },
- },
- }))
- const environmentOptions: EnvironmentFilterOption[] = environmentsQuery.data?.environments
- ?.map(environment => ({
- value: environment.id,
- text: environment.displayName,
- icon: ,
- })) ?? []
- const filterOptions: EnvironmentFilterOption[] = [
- {
- value: null,
- text: t('filter.allEnvs'),
- icon: ,
- },
- ...environmentOptions,
- ]
- const selectedOption = filterOptions.find(option => option.value === envFilter)
- ?? (envFilter
- ? {
- value: envFilter,
- text: envFilter,
- icon: ,
- }
- : filterOptions[0])
- const activeFilter = selectedOption?.value ?? null
+ const [_envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
+ const filterOptions = useAtomValue(deploymentsListEnvironmentFilterOptionsAtom)
+ const selectedOption = useAtomValue(deploymentsListSelectedEnvironmentFilterOptionAtom)
+ const activeFilter = selectedOption.value
+
+ function optionText(option: DeploymentsListEnvironmentFilterOption) {
+ return option.kind === 'all' ? t('filter.allEnvs') : option.displayName ?? option.value ?? ''
+ }
return (
@@ -75,10 +54,10 @@ export function EnvironmentFilter({ className }: {
)}
>
- {selectedOption?.icon}
+
- {selectedOption?.text}
+ {optionText(selectedOption)}
@@ -103,8 +82,10 @@ export function EnvironmentFilter({ className }: {
'cursor-pointer hover:bg-state-base-hover',
)}
>
-
{option.icon}
-
{option.text}
+
+
+
+
{optionText(option)}
{option.value === activeFilter && (
)}
diff --git a/web/features/deployments/list/ui/instance-card.tsx b/web/features/deployments/list/ui/instance-card.tsx
index 17cc60d8bc1..9a4c241fc1c 100644
--- a/web/features/deployments/list/ui/instance-card.tsx
+++ b/web/features/deployments/list/ui/instance-card.tsx
@@ -8,9 +8,9 @@ import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
-import { DeploymentActionsMenu } from '../../components/deployment-actions'
import { CreateReleaseControl } from '../../create-release'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
+import { DeploymentActionsMenu } from '../../deployment-actions'
import {
DeploymentAccessLinks,
DeploymentStatusContent,
@@ -55,7 +55,7 @@ export function InstanceCard({ summary }: {
className="group relative col-span-1 inline-flex min-h-40 min-w-0 cursor-default flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs transition-[border-color,box-shadow] duration-200 ease-in-out hover:border-components-panel-border-subtle hover:shadow-md"
>
(deploymentsListQuery)
@@ -178,9 +175,9 @@ export function DeploymentsListShell() {
>
{showSkeleton
?
- : isError
+ : showErrorState
? {t('common.loadFailed')}
- : appInstanceSummaries.length === 0
+ : showEmptyState
?
: appInstanceSummaries.map(summary => (
))}
- {isFetchingNextPage && }
+ {deploymentsListQuery.isFetchingNextPage && }
diff --git a/web/features/deployments/shared/README.md b/web/features/deployments/shared/README.md
new file mode 100644
index 00000000000..6e2819a00eb
--- /dev/null
+++ b/web/features/deployments/shared/README.md
@@ -0,0 +1,19 @@
+# Deployment Shared
+
+Shared deployment domain rules, UI primitives, hooks, and local helpers used by multiple deployment modules.
+
+## Internal Modules
+
+| Module | Why this module uses it |
+| ------------------- | ------------------------------------------------------------------------- |
+| `shared/components` | Provides reusable deployment section, table, endpoint, and binding UI. |
+| `shared/domain` | Provides reusable deployment DSL, release, runtime, and error rules. |
+| `shared/hooks` | Provides reusable hooks for deployment module workflows. |
+| `shared/ui` | Provides reusable deployment status presentation primitives and mappings. |
+
+## External Modules
+
+| Module | Why this module uses it |
+| ------------ | ----------------------------------------------------------------- |
+| `types/app` | Uses app mode enums when parsing deployment DSL content. |
+| `utils/time` | Formats release timestamps for deployment release metadata views. |
diff --git a/web/features/deployments/components/__tests__/env-var-bindings-utils.spec.ts b/web/features/deployments/shared/components/__tests__/env-var-bindings-utils.spec.ts
similarity index 100%
rename from web/features/deployments/components/__tests__/env-var-bindings-utils.spec.ts
rename to web/features/deployments/shared/components/__tests__/env-var-bindings-utils.spec.ts
diff --git a/web/features/deployments/components/__tests__/runtime-credential-bindings-utils.spec.ts b/web/features/deployments/shared/components/__tests__/runtime-credential-bindings-utils.spec.ts
similarity index 100%
rename from web/features/deployments/components/__tests__/runtime-credential-bindings-utils.spec.ts
rename to web/features/deployments/shared/components/__tests__/runtime-credential-bindings-utils.spec.ts
diff --git a/web/features/deployments/components/title-tooltip.spec.tsx b/web/features/deployments/shared/components/__tests__/title-tooltip.spec.tsx
similarity index 97%
rename from web/features/deployments/components/title-tooltip.spec.tsx
rename to web/features/deployments/shared/components/__tests__/title-tooltip.spec.tsx
index ccf92313079..290f8c859d8 100644
--- a/web/features/deployments/components/title-tooltip.spec.tsx
+++ b/web/features/deployments/shared/components/__tests__/title-tooltip.spec.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
-import { TitleTooltip } from './title-tooltip'
+import { TitleTooltip } from '../title-tooltip'
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => (
diff --git a/web/features/deployments/shared/components/detail-table-styles.ts b/web/features/deployments/shared/components/detail-table-styles.ts
new file mode 100644
index 00000000000..5a975b8d2e0
--- /dev/null
+++ b/web/features/deployments/shared/components/detail-table-styles.ts
@@ -0,0 +1,8 @@
+import { cn } from '@langgenius/dify-ui/cn'
+
+export const DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME = cn(
+ 'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
+ 'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
+ 'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
+ 'disabled:cursor-not-allowed disabled:opacity-50',
+)
diff --git a/web/features/deployments/detail/components/detail-table.tsx b/web/features/deployments/shared/components/detail-table.tsx
similarity index 100%
rename from web/features/deployments/detail/components/detail-table.tsx
rename to web/features/deployments/shared/components/detail-table.tsx
diff --git a/web/features/deployments/components/empty-state.tsx b/web/features/deployments/shared/components/empty-state.tsx
similarity index 100%
rename from web/features/deployments/components/empty-state.tsx
rename to web/features/deployments/shared/components/empty-state.tsx
diff --git a/web/features/deployments/detail/access-tab/components/endpoint.tsx b/web/features/deployments/shared/components/endpoint.tsx
similarity index 100%
rename from web/features/deployments/detail/access-tab/components/endpoint.tsx
rename to web/features/deployments/shared/components/endpoint.tsx
diff --git a/web/features/deployments/components/env-var-bindings-utils.ts b/web/features/deployments/shared/components/env-var-bindings-utils.ts
similarity index 100%
rename from web/features/deployments/components/env-var-bindings-utils.ts
rename to web/features/deployments/shared/components/env-var-bindings-utils.ts
diff --git a/web/features/deployments/components/env-var-bindings.tsx b/web/features/deployments/shared/components/env-var-bindings.tsx
similarity index 100%
rename from web/features/deployments/components/env-var-bindings.tsx
rename to web/features/deployments/shared/components/env-var-bindings.tsx
diff --git a/web/features/deployments/components/runtime-credential-bindings-utils.ts b/web/features/deployments/shared/components/runtime-credential-bindings-utils.ts
similarity index 100%
rename from web/features/deployments/components/runtime-credential-bindings-utils.ts
rename to web/features/deployments/shared/components/runtime-credential-bindings-utils.ts
diff --git a/web/features/deployments/components/runtime-credential-bindings.tsx b/web/features/deployments/shared/components/runtime-credential-bindings.tsx
similarity index 100%
rename from web/features/deployments/components/runtime-credential-bindings.tsx
rename to web/features/deployments/shared/components/runtime-credential-bindings.tsx
diff --git a/web/features/deployments/detail/components/section.tsx b/web/features/deployments/shared/components/section.tsx
similarity index 100%
rename from web/features/deployments/detail/components/section.tsx
rename to web/features/deployments/shared/components/section.tsx
diff --git a/web/features/deployments/components/title-tooltip.tsx b/web/features/deployments/shared/components/title-tooltip.tsx
similarity index 100%
rename from web/features/deployments/components/title-tooltip.tsx
rename to web/features/deployments/shared/components/title-tooltip.tsx
diff --git a/web/features/deployments/components/unsupported-dsl-nodes-alert.tsx b/web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx
similarity index 96%
rename from web/features/deployments/components/unsupported-dsl-nodes-alert.tsx
rename to web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx
index f252586ce64..067e9416916 100644
--- a/web/features/deployments/components/unsupported-dsl-nodes-alert.tsx
+++ b/web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx
@@ -1,6 +1,6 @@
'use client'
-import type { UnsupportedDslNode } from '../shared/domain/error'
+import type { UnsupportedDslNode } from '../domain/error'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
diff --git a/web/features/deployments/shared/domain/pagination.ts b/web/features/deployments/shared/domain/pagination.ts
deleted file mode 100644
index a474cddec06..00000000000
--- a/web/features/deployments/shared/domain/pagination.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { Pagination } from '@dify/contracts/enterprise/types.gen'
-
-export const DEPLOYMENT_PAGE_SIZE = 100
-export const RELEASE_HISTORY_PAGE_SIZE = 20
-export const SOURCE_APPS_PAGE_SIZE = 100
-
-export function getNextPageParamFromPagination(pagination?: Pagination) {
- const currentPage = pagination?.currentPage ?? 1
- const totalPages = pagination?.totalPages ?? 1
-
- return currentPage < totalPages ? currentPage + 1 : undefined
-}
diff --git a/web/features/deployments/shared/hooks/use-infinite-scroll.ts b/web/features/deployments/shared/hooks/use-infinite-scroll.ts
index ba865c1dd7e..46bb8b3f457 100644
--- a/web/features/deployments/shared/hooks/use-infinite-scroll.ts
+++ b/web/features/deployments/shared/hooks/use-infinite-scroll.ts
@@ -1,5 +1,5 @@
import type { RefCallback } from 'react'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef } from 'react'
type FetchNextPageOptions = {
cancelRefetch?: boolean
@@ -24,12 +24,18 @@ export type UseInfiniteScrollOptions = {
}
type UseInfiniteScrollResult