This commit is contained in:
Stephen Zhou
2026-05-22 11:45:51 +08:00
parent aaf50f5441
commit 6020261f92
12 changed files with 675 additions and 270 deletions

View File

@ -12,12 +12,18 @@ import {
} from './common'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import {
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
} from './list-styles'
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from './table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from './table-styles'
function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string
@ -44,9 +50,9 @@ function DeploymentEnvironmentListSkeleton() {
return (
<>
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
<DetailTableCardList className="pc:hidden">
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<DetailTableCard key={key}>
<div className="flex flex-col gap-3 p-4">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
@ -61,39 +67,43 @@ function DeploymentEnvironmentListSkeleton() {
</div>
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
</DetailTableCard>
))}
</div>
</DetailTableCardList>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('deployTab.col.environment')}</div>
<div>{t('deployTab.col.status')}</div>
<div>{t('deployTab.col.currentRelease')}</div>
<div className="text-right">{t('deployTab.col.actions')}</div>
</div>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableRow key={key}>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</div>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
</div>
))}
</div>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)

View File

@ -19,7 +19,7 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useRef, useState } from 'react'
import { Fragment, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
@ -30,13 +30,19 @@ import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import {
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME,
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
} from '../list-styles'
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME,
} from '../table-styles'
import { DeploymentStatusSummary } from './deployment-status-summary'
function EnvironmentSummary({ environment }: {
@ -131,7 +137,7 @@ function DeploymentRowActions({ appInstanceId, envId, row }: {
<DropdownMenu modal={false} open={actionsOpen} onOpenChange={setActionsOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
@ -236,7 +242,7 @@ function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
return (
<div className="border-b border-divider-subtle last:border-b-0">
<DetailTableCard>
<div className="flex flex-col gap-3 p-4 text-left">
<div className="flex min-w-0 flex-col gap-1">
<EnvironmentSummary environment={row.environment} />
@ -253,7 +259,7 @@ function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
<span className="min-w-0 flex-1 truncate">{row.status}</span>
</div>
)}
</div>
</DetailTableCard>
)
}
@ -270,31 +276,34 @@ function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
const isLast = index === rows.length - 1
return (
<div
key={envId}
className={DETAIL_LIST_ROW_CLASS_NAME}
>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<Fragment key={envId}>
<DetailTableRow>
<DetailTableCell>
<EnvironmentSummary environment={row.environment} />
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<DeploymentStatusSummary row={row} />
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<CurrentReleaseSummary release={row.currentRelease} />
</div>
<div className="flex w-8 justify-end">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</div>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</DetailTableCell>
</DetailTableRow>
{showFailureBanner && (
<div className={cn('flex items-center gap-2 border-t border-l-2 border-divider-subtle border-l-util-colors-red-red-500 bg-util-colors-red-red-50 px-4 py-2 system-xs-regular text-util-colors-red-red-700', isLast && 'rounded-b-lg')}>
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{row.status}</span>
</div>
<DetailTableRow className="hover:bg-transparent">
<DetailTableCell colSpan={4} className="h-auto p-0">
<div className={cn('flex items-center gap-2 border-l-2 border-l-util-colors-red-red-500 bg-util-colors-red-red-50 px-4 py-2 system-xs-regular text-util-colors-red-red-700', isLast && 'rounded-b-lg')}>
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{row.status}</span>
</div>
</DetailTableCell>
</DetailTableRow>
)}
</div>
</Fragment>
)
})}
</>
@ -309,7 +318,7 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: {
return (
<>
<div className={cn(DETAIL_LIST_CLASS_NAME, 'pc:hidden')}>
<DetailTableCardList className="pc:hidden">
{rows.map(row => (
<DeploymentEnvironmentMobileRow
key={environmentId(row.environment)}
@ -317,17 +326,21 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: {
row={row}
/>
))}
</div>
</DetailTableCardList>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('deployTab.col.environment')}</div>
<div>{t('deployTab.col.status')}</div>
<div>{t('deployTab.col.currentRelease')}</div>
<div className="text-right">{t('deployTab.col.actions')}</div>
</div>
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
</div>
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
</DetailTableBody>
</DetailTable>
</div>
</>
)

View File

@ -1,14 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
export const DETAIL_LIST_CLASS_NAME = 'overflow-hidden rounded-lg border border-divider-subtle bg-background-default'
export const DETAIL_LIST_ROW_CLASS_NAME = 'border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover'
export const DETAIL_LIST_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',
)
export const DETAIL_LIST_HEADER_ROW_CLASS_NAME = 'grid min-h-8 items-center gap-6 border-b border-divider-subtle px-4 py-1.5 system-2xs-medium-uppercase text-text-tertiary'
export const DETAIL_LIST_DESKTOP_ROW_CLASS_NAME = 'grid min-h-12 items-center gap-6 px-4 py-2'
export const DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(160px,1fr)_minmax(150px,0.75fr)_minmax(180px,1fr)_auto]'
export const RELEASE_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(150px,1fr)_minmax(130px,0.75fr)_minmax(140px,0.8fr)_minmax(150px,1fr)_auto]'

View File

@ -0,0 +1,106 @@
import type { DeveloperApiKeyRow } from '@dify/contracts/enterprise/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiKeyList } from '../api-keys'
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appDeployAccessService: {
deleteDeveloperApiKey: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
},
},
},
}))
function apiKey(overrides: Partial<DeveloperApiKeyRow> = {}): DeveloperApiKeyRow {
return {
id: 'key-1',
name: 'production-key-001',
environment: { id: 'env-1', name: 'production' },
maskedKey: 'app-****-abcd',
...overrides,
}
}
function renderApiKeyList(apiKeys: DeveloperApiKeyRow[]) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<ApiKeyList appInstanceId="instance-1" apiKeys={apiKeys} />
</QueryClientProvider>,
)
}
describe('ApiKeyList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// API keys should match the shared semantic table shell used by deployment detail tabs.
describe('Rendering', () => {
it('should render API keys with the shared detail table design', () => {
// Arrange & Act
const { container } = renderApiKeyList([apiKey()])
// Assert
const desktopWrapper = container.querySelector('.hidden.pc\\:block')
const tableContainer = desktopWrapper?.querySelector('[data-slot="deployment-detail-table-container"]')
const tableShell = desktopWrapper?.querySelector('[data-slot="deployment-detail-table"]')
const header = tableShell?.querySelector('[data-slot="deployment-detail-table-header"]')
const body = tableShell?.querySelector('[data-slot="deployment-detail-table-body"]')
const row = body?.querySelector('[data-slot="deployment-detail-table-row"]')
const head = header?.querySelector('[data-slot="deployment-detail-table-head"]')
const cell = row?.querySelector('[data-slot="deployment-detail-table-cell"]')
expect(tableContainer).toHaveClass(
'overflow-hidden',
'rounded-lg',
'border',
'border-divider-subtle',
'bg-background-default',
)
expect(tableShell?.tagName).toBe('TABLE')
expect(header?.tagName).toBe('THEAD')
expect(body?.tagName).toBe('TBODY')
expect(row?.tagName).toBe('TR')
expect(head?.tagName).toBe('TH')
expect(cell?.tagName).toBe('TD')
expect(tableShell).toHaveClass(
'w-full',
'table-fixed',
'border-collapse',
'caption-bottom',
)
expect(head).toHaveClass(
'h-9',
'px-4',
'py-2',
'system-sm-medium-uppercase',
'text-text-tertiary',
)
expect(row).toHaveClass(
'border-b',
'border-divider-subtle',
'hover:bg-background-default-hover',
)
expect(cell).toHaveClass(
'h-12',
'min-w-0',
'px-4',
'py-2',
)
expect(row?.querySelector('[data-slot="deployment-detail-table-row-content"]')).toBeNull()
expect(screen.getAllByLabelText('deployments.access.revoke')).toHaveLength(2)
})
})
})

View File

@ -13,15 +13,58 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
function ApiKeyRow({ appInstanceId, apiKey }: {
function ApiKeyName({ apiKey }: {
apiKey: DeveloperApiKeyRow
}) {
return (
<span className="block truncate system-sm-medium text-text-primary">
{apiKey.name || apiKey.id || '—'}
</span>
)
}
function EnvironmentBadge({ environment }: {
environment: DeveloperApiKeyRow['environment']
}) {
return (
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
<span className="truncate">{environmentName(environment)}</span>
</span>
)
}
function ApiKeyValue({ value }: {
value: string
}) {
return (
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{value}
</div>
</div>
)
}
function RevokeApiKeyButton({ appInstanceId, apiKey }: {
appInstanceId: string
apiKey: DeveloperApiKeyRow
}) {
const { t } = useTranslation('deployments')
const revokeApiKey = useMutation(consoleQuery.enterprise.appDeployAccessService.deleteDeveloperApiKey.mutationOptions())
const displayValue = apiKey.maskedKey || apiKey.id || '—'
const environmentLabel = environmentName(apiKey.environment)
function handleRevoke() {
const environmentId = apiKey.environment?.id
@ -38,35 +81,70 @@ function ApiKeyRow({ appInstanceId, apiKey }: {
}
return (
<tr className="border-t border-divider-subtle">
<td className="px-3 py-2.5 align-middle">
<div className="max-w-54 truncate system-sm-medium text-text-primary">
{apiKey.name || apiKey.id}
</div>
</td>
<td className="px-3 py-2.5 align-middle">
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
<span className="truncate">{environmentLabel}</span>
</span>
</td>
<td className="px-3 py-2.5 align-middle">
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{displayValue}
<button
type="button"
onClick={handleRevoke}
aria-label={t('access.revoke')}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span className="i-ri-delete-bin-line size-3.5" />
</button>
)
}
function ApiKeyMobileRow({ appInstanceId, apiKey }: {
appInstanceId: string
apiKey: DeveloperApiKeyRow
}) {
const { t } = useTranslation('deployments')
const displayValue = apiKey.maskedKey || apiKey.id || '—'
return (
<DetailTableCard>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ApiKeyName apiKey={apiKey} />
<div className="mt-1">
<EnvironmentBadge environment={apiKey.environment} />
</div>
</div>
<RevokeApiKeyButton appInstanceId={appInstanceId} apiKey={apiKey} />
</div>
</td>
<td className="px-3 py-2.5 text-right align-middle">
<button
type="button"
onClick={handleRevoke}
aria-label={t('access.revoke')}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
>
<span className="i-ri-delete-bin-line size-3.5" />
</button>
</td>
</tr>
<div className="flex min-w-0 flex-col gap-1">
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t('access.api.table.key')}
</span>
<ApiKeyValue value={displayValue} />
</div>
</div>
</DetailTableCard>
)
}
function ApiKeyDesktopRow({ appInstanceId, apiKey }: {
appInstanceId: string
apiKey: DeveloperApiKeyRow
}) {
const displayValue = apiKey.maskedKey || apiKey.id || '—'
return (
<DetailTableRow>
<DetailTableCell>
<ApiKeyName apiKey={apiKey} />
</DetailTableCell>
<DetailTableCell>
<EnvironmentBadge environment={apiKey.environment} />
</DetailTableCell>
<DetailTableCell>
<ApiKeyValue value={displayValue} />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<RevokeApiKeyButton appInstanceId={appInstanceId} apiKey={apiKey} />
</div>
</DetailTableCell>
</DetailTableRow>
)
}
@ -74,22 +152,14 @@ function ApiKeyTableHeader() {
const { t } = useTranslation('deployments')
return (
<thead>
<tr className="bg-background-default-subtle text-left system-xs-medium-uppercase text-text-tertiary">
<th className="w-56 px-3 py-2 font-medium">
{t('access.api.table.name')}
</th>
<th className="w-40 px-3 py-2 font-medium">
{t('access.api.table.environment')}
</th>
<th className="px-3 py-2 font-medium">
{t('access.api.table.key')}
</th>
<th className="w-18 px-3 py-2 text-right font-medium">
{t('access.api.table.action')}
</th>
</tr>
</thead>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t('access.api.table.name')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.api.table.environment')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t('access.api.table.key')}</DetailTableHead>
<DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('access.api.table.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
)
}
@ -98,20 +168,31 @@ function ApiKeyTable({ appInstanceId, apiKeys }: {
apiKeys: DeveloperApiKeyRow[]
}) {
return (
<div className="overflow-x-auto rounded-lg border border-divider-subtle">
<table className="w-full min-w-175 table-fixed border-collapse">
<ApiKeyTableHeader />
<tbody className="bg-components-panel-bg">
{apiKeys.map(apiKey => (
<ApiKeyRow
key={apiKey.id}
appInstanceId={appInstanceId}
apiKey={apiKey}
/>
))}
</tbody>
</table>
</div>
<>
<DetailTableCardList className={cn('pc:hidden')}>
{apiKeys.map((apiKey, index) => (
<ApiKeyMobileRow
key={apiKey.id ?? apiKey.maskedKey ?? apiKey.name ?? index}
appInstanceId={appInstanceId}
apiKey={apiKey}
/>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<ApiKeyTableHeader />
<DetailTableBody>
{apiKeys.map((apiKey, index) => (
<ApiKeyDesktopRow
key={apiKey.id ?? apiKey.maskedKey ?? apiKey.name ?? index}
appInstanceId={appInstanceId}
apiKey={apiKey}
/>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}

View File

@ -5,9 +5,20 @@ import type {
} from '@dify/contracts/enterprise/types.gen'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { Section, SectionState } from '../../common'
import {
DetailTable,
DetailTableBody,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import {
ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
import { EnvironmentPermissionRow } from './permissions'
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
@ -19,19 +30,33 @@ function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow
}
function AccessPermissionsSkeleton() {
const { t } = useTranslation('deployments')
return (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
<SkeletonRow
key={key}
className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-center"
>
<SkeletonRectangle className="h-4 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
<DetailTable>
<DetailTableHeader className="hidden pc:table-header-group">
<DetailTableRow>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.permissions.col.environment')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.permission}>{t('access.permissions.col.permission')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.subjects}>{t('access.permissions.col.subjects')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
<DetailTableRow key={key} className="block pc:table-row">
<DetailTableCell className="block h-auto px-4 pt-3 pb-1 pc:table-cell pc:h-12 pc:py-3">
<SkeletonRectangle className="h-4 w-32 animate-pulse" />
</DetailTableCell>
<DetailTableCell className="block h-auto px-4 py-1 pc:table-cell pc:h-12 pc:py-3">
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</DetailTableCell>
<DetailTableCell className="block h-auto px-4 pt-1 pb-3 pc:table-cell pc:h-12 pc:py-3">
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
)
}
@ -66,16 +91,25 @@ export function AccessPermissionsSection({
</SectionState>
)
: (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{permissionRows.map(row => (
<EnvironmentPermissionRow
key={row.environment.id}
appInstanceId={appInstanceId}
environment={row.environment}
summaryPolicy={row}
/>
))}
</div>
<DetailTable>
<DetailTableHeader className="hidden pc:table-header-group">
<DetailTableRow>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.permissions.col.environment')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.permission}>{t('access.permissions.col.permission')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.subjects}>{t('access.permissions.col.subjects')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{permissionRows.map(row => (
<EnvironmentPermissionRow
key={row.environment.id}
appInstanceId={appInstanceId}
environment={row.environment}
summaryPolicy={row}
/>
))}
</DetailTableBody>
</DetailTable>
)}
</Section>
)

View File

@ -39,6 +39,10 @@ import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import {
DetailTableCell,
DetailTableRow,
} from '../../table'
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
@ -412,20 +416,20 @@ export function EnvironmentPermissionRow({
}
return (
<div className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-start">
<div className="min-w-0 lg:pt-1.5">
<div className="system-2xs-medium-uppercase text-text-tertiary">
<DetailTableRow className="block pc:table-row">
<DetailTableCell className="block h-auto px-4 pt-3 pb-1 align-top pc:table-cell pc:h-12 pc:py-3">
<div className="system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.environment')}
</div>
<div className="mt-1 flex min-w-0 items-center gap-2">
<div className="mt-1 flex min-w-0 items-center gap-2 pc:mt-0">
<span className="i-ri-server-line size-3.5 shrink-0 text-text-tertiary" aria-hidden="true" />
<span className="min-w-0 truncate system-sm-medium text-text-primary">
{environmentName(environment)}
</span>
</div>
</div>
<div className="min-w-0">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
</DetailTableCell>
<DetailTableCell className="block h-auto px-4 py-1 align-top pc:table-cell pc:h-12 pc:py-3">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.permission')}
</div>
<PermissionPicker
@ -433,9 +437,9 @@ export function EnvironmentPermissionRow({
disabled={controlsDisabled}
onChange={handlePermissionChange}
/>
</div>
<div className="min-w-0">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
</DetailTableCell>
<DetailTableCell className="block h-auto px-4 pt-1 pb-3 align-top pc:table-cell pc:h-12 pc:py-3">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.subjects')}
</div>
{permissionKind === 'specific'
@ -458,7 +462,7 @@ export function EnvironmentPermissionRow({
{t(`access.permission.${permissionKind}Desc`)}
</div>
)}
</div>
</div>
</DetailTableCell>
</DetailTableRow>
)
}

View File

@ -0,0 +1,36 @@
import { cn } from '@langgenius/dify-ui/cn'
export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
actions: 'w-14',
currentRelease: 'w-[34%]',
environment: 'w-[34%]',
status: 'w-[24%]',
}
export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-14',
author: 'w-[18%]',
createdAt: 'w-[18%]',
deployedTo: 'w-[28%]',
release: 'w-[28%]',
}
export const ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
environment: 'w-[22%]',
permission: 'w-[28%]',
subjects: 'w-[50%]',
}
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-16',
environment: 'w-[20%]',
key: 'w-[38%]',
name: 'w-[28%]',
}
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',
)

View File

@ -0,0 +1,91 @@
import type { ComponentProps } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type DetailTableProps = ComponentProps<'table'> & {
containerClassName?: string
}
export function DetailTable({ className, containerClassName, ...props }: DetailTableProps) {
return (
<div
data-slot="deployment-detail-table-container"
className={cn('overflow-hidden rounded-lg border border-divider-subtle bg-background-default', containerClassName)}
>
<table
data-slot="deployment-detail-table"
className={cn('w-full table-fixed caption-bottom border-collapse bg-background-default', className)}
{...props}
/>
</div>
)
}
export function DetailTableHeader({ className, ...props }: ComponentProps<'thead'>) {
return (
<thead
data-slot="deployment-detail-table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
)
}
export function DetailTableBody({ className, ...props }: ComponentProps<'tbody'>) {
return (
<tbody
data-slot="deployment-detail-table-body"
className={cn('[&_tr:last-child]:border-b-0', className)}
{...props}
/>
)
}
export function DetailTableRow({ className, ...props }: ComponentProps<'tr'>) {
return (
<tr
data-slot="deployment-detail-table-row"
className={cn('border-b border-divider-subtle transition-colors hover:bg-background-default-hover', className)}
{...props}
/>
)
}
export function DetailTableHead({ className, ...props }: ComponentProps<'th'>) {
return (
<th
data-slot="deployment-detail-table-head"
className={cn('h-9 px-4 py-2 text-left align-middle system-sm-medium-uppercase whitespace-nowrap text-text-tertiary', className)}
{...props}
/>
)
}
export function DetailTableCell({ className, ...props }: ComponentProps<'td'>) {
return (
<td
data-slot="deployment-detail-table-cell"
className={cn('h-12 min-w-0 px-4 py-2 align-middle', className)}
{...props}
/>
)
}
export function DetailTableCardList({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="deployment-detail-table-card-list"
className={cn('overflow-hidden rounded-lg border border-divider-subtle bg-background-default', className)}
{...props}
/>
)
}
export function DetailTableCard({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="deployment-detail-table-card"
className={cn('border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover', className)}
{...props}
/>
)
}

View File

@ -100,31 +100,61 @@ describe('ReleaseHistoryTable', () => {
})
})
// The desktop release history should use the same compact table shell as knowledge documents.
// The desktop release history should use the shared semantic deployment detail table.
describe('Rendering', () => {
it('should render the desktop release history as a compact document-style table', () => {
it('should render the desktop release history with the shared detail table design', () => {
// Arrange & Act
const { container } = render(<ReleaseHistoryTable appInstanceId="instance-1" />)
// Assert
const table = screen.getByRole('table')
expect(table).toHaveClass('border-collapse', 'border-0', 'text-sm', 'min-w-[700px]')
expect(container.querySelector('thead')).toHaveClass(
'h-8',
'border-b',
const desktopWrapper = container.querySelector('.hidden.pc\\:block')
const tableContainer = desktopWrapper?.querySelector('[data-slot="deployment-detail-table-container"]')
const tableShell = desktopWrapper?.querySelector('[data-slot="deployment-detail-table"]')
const header = tableShell?.querySelector('[data-slot="deployment-detail-table-header"]')
const body = tableShell?.querySelector('[data-slot="deployment-detail-table-body"]')
const row = body?.querySelector('[data-slot="deployment-detail-table-row"]')
const head = header?.querySelector('[data-slot="deployment-detail-table-head"]')
const cell = row?.querySelector('[data-slot="deployment-detail-table-cell"]')
expect(tableContainer).toHaveClass(
'overflow-hidden',
'rounded-lg',
'border',
'border-divider-subtle',
'text-xs',
'leading-8',
'font-medium',
'text-text-tertiary',
'uppercase',
'bg-background-default',
)
expect(container.querySelector('tbody tr')).toHaveClass(
'h-8',
expect(tableShell?.tagName).toBe('TABLE')
expect(header?.tagName).toBe('THEAD')
expect(body?.tagName).toBe('TBODY')
expect(row?.tagName).toBe('TR')
expect(head?.tagName).toBe('TH')
expect(cell?.tagName).toBe('TD')
expect(tableShell).toHaveClass(
'w-full',
'table-fixed',
'border-collapse',
'caption-bottom',
)
expect(head).toHaveClass(
'h-9',
'px-4',
'py-2',
'system-sm-medium-uppercase',
'text-text-tertiary',
)
expect(row).toHaveClass(
'border-b',
'border-divider-subtle',
'hover:bg-background-default-hover',
)
expect(cell).toHaveClass(
'h-12',
'min-w-0',
'px-4',
'py-2',
)
expect(row?.querySelector('[data-slot="deployment-detail-table-row-content"]')).toBeNull()
expect(screen.getAllByText('R-001')).toHaveLength(2)
})
})
})

View File

@ -17,7 +17,7 @@ import { environmentId, environmentName } from '../../environment'
import { releaseDeploymentAction } from '../../release-action'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME } from '../list-styles'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
type EnvironmentOption = AppDeployEnvironment & {
id: string
@ -132,7 +132,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('versions.moreActions')}
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>

View File

@ -21,12 +21,18 @@ import {
DetailListState,
} from '../common'
import {
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
RELEASE_DETAIL_LIST_GRID_CLASS_NAME,
} from '../list-styles'
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import {
RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../table-styles'
import { DeployReleaseMenu } from './deploy-release-menu'
import { DeployedToBadge } from './deployed-to-badge'
import { getReleaseDeployments } from './release-deployments'
@ -46,9 +52,9 @@ function ReleaseHistoryTableSkeleton() {
return (
<>
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
<DetailTableCardList className="pc:hidden">
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<DetailTableCard key={key}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -64,40 +70,44 @@ function ReleaseHistoryTableSkeleton() {
<ReleaseDeploymentsSkeleton />
</div>
</div>
</div>
</DetailTableCard>
))}
</div>
</DetailTableCardList>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('versions.col.release')}</div>
<div>{t('versions.col.createdAt')}</div>
<div>{t('versions.col.author')}</div>
<div>{t('versions.col.deployedTo')}</div>
<div className="text-right">{t('versions.col.action')}</div>
</div>
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableRow key={key}>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<ReleaseDeploymentsSkeleton />
</div>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
</div>
))}
</div>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
@ -113,14 +123,14 @@ function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows,
const { t } = useTranslation('deployments')
return (
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
<DetailTableCardList className="pc:hidden">
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
const hasDeployments = releaseDeployments.length > 0 || deployedToLoading || deployedToHasError
return (
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
<DetailTableCard key={release.id}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -157,10 +167,10 @@ function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows,
</div>
)}
</div>
</div>
</DetailTableCard>
)
})}
</div>
</DetailTableCardList>
)
}
@ -243,22 +253,24 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deploy
deployedToHasError={deployedToHasError}
/>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('versions.col.release')}</div>
<div>{t('versions.col.createdAt')}</div>
<div>{t('versions.col.author')}</div>
<div>{t('versions.col.deployedTo')}</div>
<div className="text-right">{t('versions.col.action')}</div>
</div>
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
return (
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
return (
<DetailTableRow key={release.id}>
<DetailTableCell>
<Tooltip>
<TooltipTrigger
render={(
@ -271,14 +283,14 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deploy
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
</div>
<div className="min-w-0 system-sm-regular text-text-secondary">
</DetailTableCell>
<DetailTableCell className="system-sm-regular text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
</div>
<div className="min-w-0 truncate system-sm-regular text-text-secondary">
</DetailTableCell>
<DetailTableCell className="truncate system-sm-regular text-text-secondary">
{row.createdBy?.name ?? '—'}
</div>
<div className="min-w-0">
</DetailTableCell>
<DetailTableCell>
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={releaseDeployments}
@ -287,15 +299,17 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deploy
loadFailedLabel={t('common.loadFailed')}
/>
</div>
</div>
<div className="flex justify-end">
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</div>
</div>
)
})}
</div>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</DetailTableCell>
</DetailTableRow>
)
})}
</DetailTableBody>
</DetailTable>
</div>
</>
)