refactor: align deployment detail state ownership (#38008)

This commit is contained in:
Stephen Zhou 2026-06-26 13:37:21 +08:00 committed by GitHub
parent a66b1de477
commit 113d6d7e00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 167 additions and 212 deletions

View File

@ -49,6 +49,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
@ -88,11 +89,13 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.

View File

@ -1,8 +1,5 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
export default async function InstanceDetailInstancesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeployTab appInstanceId={appInstanceId} />
export default function InstanceDetailInstancesPage() {
return <DeployTab />
}

View File

@ -1,14 +1,11 @@
import type { ReactNode } from 'react'
import { InstanceDetail } from '@/features/deployments/detail'
export default async function InstanceDetailLayout({ children, params }: {
export default function InstanceDetailLayout({ children }: {
children: ReactNode
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return (
<InstanceDetail appInstanceId={appInstanceId}>
<InstanceDetail>
{children}
</InstanceDetail>
)

View File

@ -1,8 +1,5 @@
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
export default async function InstanceDetailOverviewPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <OverviewTab appInstanceId={appInstanceId} />
export default function InstanceDetailOverviewPage() {
return <OverviewTab />
}

View File

@ -1,8 +1,5 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
export default async function InstanceDetailReleasesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <VersionsTab appInstanceId={appInstanceId} />
export default function InstanceDetailReleasesPage() {
return <VersionsTab />
}

View File

@ -24,16 +24,6 @@ vi.mock('jotai-tanstack-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
get: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['appById', options.input],
}),
},
},
},
enterprise: {
appInstanceService: {
getAppInstance: {
@ -89,18 +79,13 @@ describe('deployment detail state', () => {
enabled: false,
input: skipToken,
})
expect(store.get(state.deploymentSourceAppQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
})
it('should build detail query inputs from route and source identities', async () => {
it('should build detail query inputs from route identity', async () => {
const state = await loadState()
const store = createStore()
setDeploymentRoute(store)
store.set(state.deploymentSourceAppIdAtom, 'source-app-1')
expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({
enabled: true,
@ -117,10 +102,5 @@ describe('deployment detail state', () => {
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(environmentDeploymentsQuery.refetchInterval).toEqual(expect.any(Function))
expect(store.get(state.deploymentSourceAppQueryAtom)).toMatchObject({
enabled: true,
input: { params: { app_id: 'source-app-1' } },
})
})
})

View File

@ -16,12 +16,17 @@ vi.mock('jotai', async (importOriginal) => {
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
isPending: false,
mutate: mockToggleAccessChannel,
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: () => ({
isPending: false,
mutate: mockToggleAccessChannel,
}),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {

View File

@ -20,22 +20,23 @@ vi.mock('jotai', async (importOriginal) => {
}
})
vi.mock('@tanstack/react-query', () => ({
useInfiniteQuery: () => ({
data: { pages: [] },
fetchNextPage: vi.fn(),
isFetchingNextPage: false,
isLoading: false,
}),
useMutation: () => ({
isPending: false,
mutate: mockMutate,
}),
useQuery: () => ({
data: undefined,
isPending: false,
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: { pages: [] },
fetchNextPage: vi.fn(),
isFetchingNextPage: false,
isLoading: false,
}),
useMutation: () => ({
isPending: false,
mutate: mockMutate,
}),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {

View File

@ -72,8 +72,7 @@ function CurrentReleaseMobileSummary({ release }: {
)
}
function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
appInstanceId: string
function DeploymentEnvironmentMobileRow({ row }: {
row: EnvironmentDeployment
}) {
const envId = row.environment.id
@ -88,15 +87,14 @@ function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
</div>
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
<div className="flex min-w-0 items-center justify-start gap-2">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
<DeploymentRowActions envId={envId} row={row} />
</div>
</div>
</DetailTableCard>
)
}
function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
appInstanceId: string
function DeploymentEnvironmentDesktopRows({ rows }: {
rows: EnvironmentDeployment[]
}) {
return (
@ -116,7 +114,7 @@ function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
</DetailTableCell>
<DetailTableCell className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions}>
<div className="flex min-h-8 justify-end">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
<DeploymentRowActions envId={envId} row={row} />
</div>
</DetailTableCell>
</DetailTableRow>
@ -126,8 +124,7 @@ function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
)
}
export function DeploymentEnvironmentList({ appInstanceId, rows }: {
appInstanceId: string
export function DeploymentEnvironmentList({ rows }: {
rows: EnvironmentDeployment[]
}) {
const { t } = useTranslation('deployments')
@ -138,7 +135,6 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: {
{rows.map(row => (
<DeploymentEnvironmentMobileRow
key={row.environment.id}
appInstanceId={appInstanceId}
row={row}
/>
))}
@ -154,7 +150,7 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: {
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
<DeploymentEnvironmentDesktopRows rows={rows} />
</DetailTableBody>
</DetailTable>
</div>

View File

@ -3,23 +3,24 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
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 { DeploymentErrorDialog } from './deployment-error-dialog'
import { DeploymentActionsDropdown } from './deployment-row-actions-menu'
import { UndeployDeploymentDialog } from './undeploy-deployment-dialog'
export function DeploymentRowActions({ appInstanceId, envId, row }: {
appInstanceId: string
export function DeploymentRowActions({ envId, row }: {
envId: string
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
const routeAppInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions())
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
@ -36,6 +37,11 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
? t('deployDrawer.deploy')
: t('deployTab.deployOtherVersion')
if (!routeAppInstanceId)
return null
const appInstanceId = routeAppInstanceId
function handleDeployAction(releaseId?: string) {
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId })
}

View File

@ -87,9 +87,7 @@ function DeploymentEnvironmentListSkeleton() {
)
}
export function DeployTab({ appInstanceId }: {
appInstanceId: string
}) {
export function DeployTab() {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const environmentDeployments = environmentDeploymentsQuery.data
@ -109,11 +107,11 @@ export function DeployTab({ appInstanceId }: {
icon="i-ri-server-line"
title={t('deployTab.emptyTitle')}
description={t('deployTab.emptyDescription')}
action={<NewDeploymentButton appInstanceId={appInstanceId} />}
action={<NewDeploymentButton />}
/>
)
: (
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
<DeploymentEnvironmentList rows={rows} />
)}
</div>
)

View File

@ -4,13 +4,13 @@ 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'
export function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string
}) {
export function NewDeploymentButton() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
return (
@ -18,7 +18,12 @@ export function NewDeploymentButton({ appInstanceId }: {
size="medium"
variant="primary"
className="gap-1.5"
onClick={() => openDeployDrawer({ appInstanceId })}
disabled={!appInstanceId}
onClick={() => {
if (!appInstanceId)
return
openDeployDrawer({ appInstanceId })
}}
>
<span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" />
{t('deployTab.newDeployment')}
@ -26,14 +31,12 @@ export function NewDeploymentButton({ appInstanceId }: {
)
}
export function NewDeploymentHeaderAction({ appInstanceId }: {
appInstanceId: string
}) {
export function NewDeploymentHeaderAction() {
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const rows = environmentDeploymentsQuery.data?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0)
return null
return <NewDeploymentButton appInstanceId={appInstanceId} />
return <NewDeploymentButton />
}

View File

@ -2,12 +2,14 @@
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { useAtomValue } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { CreateReleaseControl } from '../create-release'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { DeveloperApiHeaderSwitch } from './access-tab/developer-api/section'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
@ -43,17 +45,20 @@ function MobileDetailTabs({ appInstanceId, activeTab }: {
)
}
export function InstanceDetail({ appInstanceId, children }: {
appInstanceId: string
export function InstanceDetail({ children }: {
children: ReactNode
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
useDocumentTitle(t('documentTitle.detail'))
if (!appInstanceId)
return null
return (
<ScopeProvider
key={appInstanceId}
@ -82,7 +87,7 @@ export function InstanceDetail({ appInstanceId, children }: {
{(activeTab === 'instances' || activeTab === 'releases') && (
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
{activeTab === 'instances'
? <NewDeploymentHeaderAction appInstanceId={appInstanceId} />
? <NewDeploymentHeaderAction />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}

View File

@ -3,19 +3,19 @@
import type { AccessChannels, ApiKeySummary } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
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'
type AccessStatusSectionProps = {
appInstanceId: string
accessChannels?: AccessChannels
}
type ApiTokenSummarySectionProps = {
appInstanceId: string
accessChannels?: AccessChannels
apiKeySummary?: ApiKeySummary
deployedEnvironmentCount: number
@ -32,8 +32,13 @@ type AccessStatusItem = {
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli']
export function AccessStatusSection({ appInstanceId, accessChannels }: AccessStatusSectionProps) {
export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
if (!appInstanceId)
return null
const items: AccessStatusItem[] = [
{
key: 'webapp',
@ -100,15 +105,18 @@ export function AccessStatusSection({ appInstanceId, accessChannels }: AccessSta
}
export function ApiTokenSummarySection({
appInstanceId,
accessChannels,
apiKeySummary,
deployedEnvironmentCount,
}: ApiTokenSummarySectionProps) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const apiEnabled = Boolean(accessChannels?.developerApiEnabled)
const apiKeyCount = apiKeySummary?.apiKeyCount ?? 0
if (!appInstanceId)
return null
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">

View File

@ -3,12 +3,13 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
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 { EnvironmentTile } from './environment-tile'
@ -16,13 +17,13 @@ import { EnvironmentTile } from './environment-tile'
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4
type EnvironmentStripProps = {
appInstanceId: string
rows: EnvironmentDeployment[]
releaseRows: Release[]
}
export function EnvironmentStrip({ appInstanceId, rows, releaseRows }: EnvironmentStripProps) {
export function EnvironmentStrip({ rows, releaseRows }: EnvironmentStripProps) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)
const previewRows = runtimeRows.slice(0, OVERVIEW_RUNTIME_INSTANCE_LIMIT)
const hasRuntimeRows = runtimeRows.length > 0
@ -32,7 +33,7 @@ export function EnvironmentStrip({ appInstanceId, rows, releaseRows }: Environme
<section className="flex flex-col gap-3">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
{hasRuntimeRows && (
{hasRuntimeRows && appInstanceId && (
<Link
href={`/deployments/${appInstanceId}/instances`}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
@ -44,13 +45,12 @@ export function EnvironmentStrip({ appInstanceId, rows, releaseRows }: Environme
</div>
{!hasRuntimeRows
? <EnvironmentEmptyState appInstanceId={appInstanceId} canDeploy={hasRelease} />
? <EnvironmentEmptyState canDeploy={hasRelease} />
: (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-3">
{previewRows.map(row => (
<EnvironmentTile
key={row.environment.id}
appInstanceId={appInstanceId}
row={row}
releaseRows={releaseRows}
/>
@ -61,11 +61,11 @@ export function EnvironmentStrip({ appInstanceId, rows, releaseRows }: Environme
)
}
function EnvironmentEmptyState({ appInstanceId, canDeploy }: {
appInstanceId: string
function EnvironmentEmptyState({ canDeploy }: {
canDeploy: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
return (
@ -75,7 +75,7 @@ function EnvironmentEmptyState({ appInstanceId, canDeploy }: {
title={t('overview.strip.emptyTitle')}
description={canDeploy ? t('overview.strip.emptyDeployableDescription') : t('overview.strip.emptyDescription')}
className="min-h-44"
action={canDeploy
action={canDeploy && appInstanceId
? (
<Button
type="button"

View File

@ -7,11 +7,12 @@ import type {
} from '@dify/contracts/enterprise/types.gen'
import type { TileConfig } from './environment-tile-utils'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
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 {
@ -27,13 +28,13 @@ import {
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentTileProps = {
appInstanceId: string
row: EnvironmentDeployment
releaseRows: Release[]
}
export function EnvironmentTile({ appInstanceId, row, releaseRows }: EnvironmentTileProps) {
export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const envId = row.environment.id
@ -55,7 +56,7 @@ export function EnvironmentTile({ appInstanceId, row, releaseRows }: Environment
: undefined
function handleDrawerAction() {
if (config.intent === 'disabled')
if (config.intent === 'disabled' || !appInstanceId)
return
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId })
}
@ -66,7 +67,7 @@ export function EnvironmentTile({ appInstanceId, row, releaseRows }: Environment
isDisabled && 'cursor-not-allowed opacity-60',
)
const actionLabel = renderActionLabel(config.kind, Boolean(currentReleaseId), t)
const actionControl = config.intent === 'navigate'
const actionControl = config.intent === 'navigate' && appInstanceId
? (
<Link
href={`/deployments/${appInstanceId}/instances`}
@ -78,7 +79,7 @@ export function EnvironmentTile({ appInstanceId, row, releaseRows }: Environment
: (
<button
type="button"
disabled={isDisabled}
disabled={isDisabled || !appInstanceId}
onClick={handleDrawerAction}
className={actionClassName}
>

View File

@ -4,6 +4,7 @@ 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 { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
import { deploymentDetailOverviewQueryAtom } from '../state'
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-status-section'
@ -18,11 +19,11 @@ function OverviewLayout({ children }: { children: React.ReactNode }) {
)
}
function LatestReleaseSection({ appInstanceId, children }: {
appInstanceId: string
function LatestReleaseSection({ children }: {
children: React.ReactNode
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
return (
<section className="flex min-w-0 flex-col gap-3">
@ -30,13 +31,15 @@ function LatestReleaseSection({ appInstanceId, children }: {
<h3 className="system-sm-semibold text-text-primary">
{t('overview.latestReleaseTitle')}
</h3>
<Link
href={`/deployments/${appInstanceId}/releases`}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
>
{t('overview.previousReleases.viewAll')}
<span aria-hidden className="i-ri-arrow-right-line size-3.5" />
</Link>
{appInstanceId && (
<Link
href={`/deployments/${appInstanceId}/releases`}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
>
{t('overview.previousReleases.viewAll')}
<span aria-hidden className="i-ri-arrow-right-line size-3.5" />
</Link>
)}
</div>
<div className="flex min-w-0 flex-col gap-3">
{children}
@ -45,13 +48,11 @@ function LatestReleaseSection({ appInstanceId, children }: {
)
}
function OverviewLoadingSkeleton({ appInstanceId }: {
appInstanceId: string
}) {
function OverviewLoadingSkeleton() {
return (
<OverviewLayout>
<EnvironmentStripSkeleton />
<LatestReleaseSection appInstanceId={appInstanceId}>
<LatestReleaseSection>
<ReleaseHeroSkeleton />
</LatestReleaseSection>
<AccessStatusSectionSkeleton />
@ -60,15 +61,13 @@ function OverviewLoadingSkeleton({ appInstanceId }: {
)
}
export function OverviewTab({ appInstanceId }: {
appInstanceId: string
}) {
export function OverviewTab() {
const { t } = useTranslation('deployments')
const overviewQuery = useAtomValue(deploymentDetailOverviewQueryAtom)
const overview = overviewQuery.data
if (overviewQuery.isLoading)
return <OverviewLoadingSkeleton appInstanceId={appInstanceId} />
return <OverviewLoadingSkeleton />
if (overviewQuery.isError) {
return (
@ -98,20 +97,17 @@ export function OverviewTab({ appInstanceId }: {
return (
<OverviewLayout>
<EnvironmentStrip
appInstanceId={appInstanceId}
rows={runtimeRows}
releaseRows={releaseRows}
/>
<LatestReleaseSection appInstanceId={appInstanceId}>
<LatestReleaseSection>
<ReleaseHero
appInstanceId={appInstanceId}
latestRelease={latestRelease}
releaseCount={releaseCount}
/>
</LatestReleaseSection>
<AccessStatusSection appInstanceId={appInstanceId} accessChannels={accessChannels} />
<AccessStatusSection accessChannels={accessChannels} />
<ApiTokenSummarySection
appInstanceId={appInstanceId}
accessChannels={accessChannels}
apiKeySummary={apiKeySummary}
deployedEnvironmentCount={deployedEnvironmentCount}

View File

@ -4,24 +4,21 @@ import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { ReleaseSource } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { 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 {
deploymentSourceAppIdAtom,
deploymentSourceAppQueryAtom,
} from '../state'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
type ReleaseHeroProps = {
appInstanceId: string
latestRelease?: Release
releaseCount: number
}
@ -32,8 +29,9 @@ type ReleaseMetaItemProps = {
children: ReactNode
}
export function ReleaseHero({ appInstanceId, latestRelease, releaseCount }: ReleaseHeroProps) {
export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const { formatTimeFromNow } = useFormatTimeFromNow()
if (!latestRelease) {
@ -43,7 +41,7 @@ export function ReleaseHero({ appInstanceId, latestRelease, releaseCount }: Rele
icon="i-ri-stack-line"
title={t('overview.hero.empty')}
description={t('overview.hero.emptyDescription')}
action={<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
action={appInstanceId ? <CreateReleaseControl appInstanceId={appInstanceId} size="medium" /> : undefined}
className="min-h-44"
/>
)
@ -129,23 +127,17 @@ function LatestReleaseSource({ release }: {
)
}
return (
<ScopeProvider
key={sourceAppId}
atoms={[
[deploymentSourceAppIdAtom, sourceAppId],
]}
name="DeploymentLatestReleaseSource"
>
<LatestReleaseSourceLink sourceAppId={sourceAppId} />
</ScopeProvider>
)
return <LatestReleaseSourceLink sourceAppId={sourceAppId} />
}
function LatestReleaseSourceLink({ sourceAppId }: {
sourceAppId: string
}) {
const sourceAppQuery = useAtomValue(deploymentSourceAppQueryAtom)
const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
input: {
params: { app_id: sourceAppId },
},
}))
const sourceAppName = sourceAppQuery.data?.name
const label = sourceAppName || sourceAppId
const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId

View File

@ -1,14 +1,11 @@
'use client'
import { 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 { deploymentStatusPollingInterval } from '../shared/domain/runtime-status'
export const deploymentSourceAppIdAtom = atom<string | undefined>(undefined)
export const deploymentDetailAppInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
@ -48,14 +45,3 @@ export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) =>
refetchInterval: query => deploymentStatusPollingInterval(query.state.data?.environmentDeployments),
})
})
export const deploymentSourceAppQueryAtom = atomWithQuery((get) => {
const sourceAppId = get(deploymentSourceAppIdAtom)
return consoleQuery.apps.byAppId.get.queryOptions({
input: sourceAppId
? { params: { app_id: sourceAppId } }
: skipToken,
enabled: Boolean(sourceAppId),
})
})

View File

@ -103,7 +103,6 @@ describe('DeployReleaseMenu', () => {
render(
<DeployReleaseMenu
appInstanceId="app-instance-1"
releaseId={release.id}
releaseRows={[release]}
/>,

View File

@ -8,14 +8,12 @@ vi.mock('../deploy-release-menu', () => ({
DeployReleaseMenu: () => <button type="button">Actions</button>,
}))
vi.mock('../../state', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../state')>()
const { atom } = await import('jotai')
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
deploymentSourceAppIdAtom: atom<string | undefined>(undefined),
deploymentSourceAppQueryAtom: atom({
useQuery: () => ({
data: {
name: 'Source Workflow',
},
@ -23,6 +21,18 @@ vi.mock('../../state', async (importOriginal) => {
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
get: {
queryOptions: (options: unknown) => options,
},
},
},
},
}))
function createReleaseRow(overrides: Partial<ReleaseWithSummaryDeployments> = {}): ReleaseWithSummaryDeployments {
return {
id: 'release-1',
@ -46,7 +56,6 @@ describe('ReleaseHistoryRows', () => {
it('should render the desktop release list with the knowledge table style', () => {
const { container } = render(
<ReleaseHistoryRows
appInstanceId="app-instance-1"
releaseRows={[createReleaseRow()]}
/>,
)
@ -67,7 +76,6 @@ describe('ReleaseHistoryRows', () => {
it('should render release deployments with the dot status style', () => {
const { container } = render(
<ReleaseHistoryRows
appInstanceId="app-instance-1"
releaseRows={[
createReleaseRow({
summaryDeployments: [{
@ -93,7 +101,6 @@ describe('ReleaseHistoryRows', () => {
it('should render release source app links with scoped source app state', () => {
const { container } = render(
<ReleaseHistoryRows
appInstanceId="app-instance-1"
releaseRows={[
createReleaseRow({
sourceAppId: 'source-app-1',

View File

@ -16,6 +16,7 @@ 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 { DeleteReleaseDialog } from './delete-release-dialog'
@ -38,13 +39,13 @@ type ExportReleaseDslInput = {
appInstanceName?: string
}
export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDeleted }: {
appInstanceId: string
export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
releaseId: string
releaseRows: Release[]
onDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const openReleaseMenuId = useAtomValue(deployReleaseMenuOpenReleaseIdAtom)
const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom)
@ -194,7 +195,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
isDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (isDisabled)
if (isDisabled || !appInstanceId)
return
handleOpenChange(false)
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })

View File

@ -1,12 +1,10 @@
'use client'
import { ReleaseHistoryTable } from './release-history-table'
export function VersionsTab({ appInstanceId }: {
appInstanceId: string
}) {
export function VersionsTab() {
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">
<ReleaseHistoryTable appInstanceId={appInstanceId} />
<ReleaseHistoryTable />
</div>
)
}

View File

@ -4,11 +4,11 @@ import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { ReleaseWithSummaryDeployments } from './release-deployments'
import { ReleaseSource } from '@dify/contracts/enterprise/types.gen'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useAtomValue } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useQuery } from '@tanstack/react-query'
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,
@ -25,10 +25,6 @@ import {
DetailTableRow,
} from '../components/detail-table'
import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
import {
deploymentSourceAppIdAtom,
deploymentSourceAppQueryAtom,
} from '../state'
import { DeployReleaseMenu } from './deploy-release-menu'
import {
ReleaseDeploymentsContent,
@ -90,23 +86,17 @@ function ReleaseSourceCell({ release }: {
)
}
return (
<ScopeProvider
key={sourceAppId}
atoms={[
[deploymentSourceAppIdAtom, sourceAppId],
]}
name="DeploymentReleaseSource"
>
<ReleaseSourceLink sourceAppId={sourceAppId} />
</ScopeProvider>
)
return <ReleaseSourceLink sourceAppId={sourceAppId} />
}
function ReleaseSourceLink({ sourceAppId }: {
sourceAppId: string
}) {
const sourceAppQuery = useAtomValue(deploymentSourceAppQueryAtom)
const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
input: {
params: { app_id: sourceAppId },
},
}))
const sourceAppName = sourceAppQuery.data?.name
const label = sourceAppName || sourceAppId
const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId
@ -126,8 +116,7 @@ function ReleaseSourceLink({ sourceAppId }: {
)
}
function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, onReleaseDeleted }: {
appInstanceId: string
function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
@ -164,7 +153,6 @@ function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, onReleaseDeleted
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu
releaseId={releaseId}
appInstanceId={appInstanceId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
@ -185,8 +173,7 @@ function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, onReleaseDeleted
)
}
export function ReleaseHistoryRows({ appInstanceId, releaseRows, onReleaseDeleted }: {
appInstanceId: string
export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
@ -195,7 +182,6 @@ export function ReleaseHistoryRows({ appInstanceId, releaseRows, onReleaseDelete
return (
<>
<ReleaseHistoryMobileRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
onReleaseDeleted={onReleaseDeleted}
/>
@ -241,7 +227,6 @@ export function ReleaseHistoryRows({ appInstanceId, releaseRows, onReleaseDelete
<div className="flex justify-end">
<DeployReleaseMenu
releaseId={releaseId}
appInstanceId={appInstanceId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>

View File

@ -15,9 +15,7 @@ import {
setReleaseHistoryCurrentPageAtom,
} from './state'
export function ReleaseHistoryTable({ appInstanceId }: {
appInstanceId: string
}) {
export function ReleaseHistoryTable() {
const { t } = useTranslation('deployments')
const currentPage = useAtomValue(releaseHistoryCurrentPageAtom)
const setCurrentPage = useSetAtom(setReleaseHistoryCurrentPageAtom)
@ -67,7 +65,6 @@ export function ReleaseHistoryTable({ appInstanceId }: {
return (
<div className="flex flex-col gap-3">
<ReleaseHistoryRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
onReleaseDeleted={handleReleaseDeleted}
/>