mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 19:28:01 +08:00
Compare commits
5 Commits
build/appd
...
4-27-app-d
| Author | SHA1 | Date | |
|---|---|---|---|
| df5238b488 | |||
| 28cb44d06c | |||
| 612adc24bd | |||
| 632cd60741 | |||
| 09ea1786f4 |
@ -18,6 +18,14 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
|
||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
|
||||
|
||||
## Feature Workflow Layout
|
||||
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
|
||||
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
|
||||
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
|
||||
- Folders represent groups of related files with a shared owner and a stable reason to change together.
|
||||
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
|
||||
@ -31,6 +39,17 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
|
||||
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
|
||||
|
||||
## Feature-Scoped Jotai State
|
||||
|
||||
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
|
||||
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
|
||||
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
|
||||
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
|
||||
- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
|
||||
- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow.
|
||||
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
|
||||
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
|
||||
|
||||
## Components, Props, And Types
|
||||
|
||||
- Type component signatures directly; do not use `FC` or `React.FC`.
|
||||
@ -104,4 +123,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
## Navigation And Performance
|
||||
|
||||
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
|
||||
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
|
||||
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
|
||||
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { DeployDrawer } from '@/features/deployments/components/deploy-drawer'
|
||||
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
|
||||
|
||||
export default function DeploymentsLayout({ children }: {
|
||||
children: ReactNode
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deploymentStatusPollingInterval,
|
||||
isAvailableDeploymentTarget,
|
||||
isUndeployedDeploymentRow,
|
||||
} from '../runtime-status'
|
||||
|
||||
const environment = {
|
||||
id: 'env-undeploying',
|
||||
displayName: 'Test CPU',
|
||||
description: '',
|
||||
mode: 'ENVIRONMENT_MODE_SHARED',
|
||||
backend: 'RUNTIME_BACKEND_K8S',
|
||||
namespace: '',
|
||||
apiServer: '',
|
||||
status: 'ENVIRONMENT_STATUS_READY',
|
||||
statusMessage: '',
|
||||
managedBy: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
runtimeEndpoint: '',
|
||||
cpuCount: 1,
|
||||
} satisfies EnvironmentDeployment['environment']
|
||||
|
||||
describe('runtime-status', () => {
|
||||
// Runtime statuses come from the generated enterprise API contract.
|
||||
describe('deployment status helpers', () => {
|
||||
it('should not treat undeploying rows as undeployed when release fields are empty', () => {
|
||||
const row = {
|
||||
appInstanceId: 'app-instance',
|
||||
environment,
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
|
||||
updatedAt: '',
|
||||
} satisfies EnvironmentDeployment
|
||||
|
||||
expect(isUndeployedDeploymentRow(row)).toBe(false)
|
||||
expect(isAvailableDeploymentTarget(row)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deployment status polling', () => {
|
||||
it('should keep polling while a deployment is undeploying', () => {
|
||||
const rows = [{
|
||||
appInstanceId: 'app-instance',
|
||||
environment,
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
|
||||
updatedAt: '',
|
||||
}] satisfies EnvironmentDeployment[]
|
||||
|
||||
expect(deploymentStatusPollingInterval(rows)).toBe(3000)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,98 +0,0 @@
|
||||
import type { EnvVarBindingSlot, EnvVarValues } from '../env-var-bindings'
|
||||
import { EnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EnvVarBindingsPanel } from '../env-var-bindings'
|
||||
|
||||
function createEnvVarSlot(overrides: Partial<EnvVarBindingSlot> = {}): EnvVarBindingSlot {
|
||||
return {
|
||||
key: 'API_KEY',
|
||||
description: 'API key',
|
||||
defaultValue: 'unused-default',
|
||||
hasDefaultValue: false,
|
||||
hasLastValue: false,
|
||||
lastValue: 'unused-last',
|
||||
valueType: 'string',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderEnvVarBindingsPanel({
|
||||
defaultSourcePriority,
|
||||
slot,
|
||||
values = {},
|
||||
}: {
|
||||
defaultSourcePriority?: 'dslDefault' | 'lastDeployment'
|
||||
slot: EnvVarBindingSlot
|
||||
values?: EnvVarValues
|
||||
}) {
|
||||
return render(
|
||||
<EnvVarBindingsPanel
|
||||
slots={[slot]}
|
||||
values={values}
|
||||
title="Environment Variables"
|
||||
hint="Choose values."
|
||||
envVarPlaceholder="Enter value"
|
||||
literalSourceLabel="Custom value"
|
||||
defaultSourceLabel="App value"
|
||||
lastDeploymentSourceLabel="Last deployed value"
|
||||
valueTypeLabels={{
|
||||
number: 'Number',
|
||||
secret: 'Secret',
|
||||
string: 'String',
|
||||
}}
|
||||
sourceAriaLabel={key => `Select source for ${key}`}
|
||||
defaultSourcePriority={defaultSourcePriority}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// The panel resolves display values directly from slot metadata when the form state is empty.
|
||||
describe('EnvVarBindingsPanel', () => {
|
||||
it('should show the app default value by default when both sources exist', () => {
|
||||
renderEnvVarBindingsPanel({
|
||||
slot: createEnvVarSlot({
|
||||
hasDefaultValue: true,
|
||||
defaultValue: 'app-value',
|
||||
hasLastValue: true,
|
||||
lastValue: 'last-value',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(screen.getByDisplayValue('app-value')).toBeDisabled()
|
||||
expect(screen.getByText('App value')).toBeInTheDocument()
|
||||
expect(screen.getByText('Last deployed value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the last deployed value when that source is prioritized', () => {
|
||||
renderEnvVarBindingsPanel({
|
||||
defaultSourcePriority: 'lastDeployment',
|
||||
slot: createEnvVarSlot({
|
||||
hasDefaultValue: true,
|
||||
defaultValue: 'app-value',
|
||||
hasLastValue: true,
|
||||
lastValue: 'last-value',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(screen.getByDisplayValue('last-value')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show an existing manual value when the user changed the field', () => {
|
||||
renderEnvVarBindingsPanel({
|
||||
slot: createEnvVarSlot({
|
||||
hasDefaultValue: true,
|
||||
defaultValue: 'app-value',
|
||||
}),
|
||||
values: {
|
||||
API_KEY: {
|
||||
value: 'manual-value',
|
||||
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByDisplayValue('manual-value')).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@ -1,172 +0,0 @@
|
||||
import type { Environment, EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
EnvironmentMode,
|
||||
EnvironmentStatus,
|
||||
ReleaseSource,
|
||||
RuntimeBackend,
|
||||
RuntimeInstanceStatus,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { deployDrawerOpenAtom } from '../../../store'
|
||||
import {
|
||||
deployReadyFormConfigAtom,
|
||||
deployReadyFormLocalAtoms,
|
||||
useDeployReleaseSubmission,
|
||||
} from '../use-deploy-ready-form'
|
||||
|
||||
type MutationCallbackOptions = {
|
||||
onError?: (...args: unknown[]) => unknown
|
||||
onSuccess?: (...args: unknown[]) => unknown
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutateCalls: [] as Array<{
|
||||
options: MutationCallbackOptions
|
||||
perCallOptions?: MutationCallbackOptions
|
||||
variables: unknown
|
||||
}>,
|
||||
promoteBaseOnSuccess: vi.fn(),
|
||||
rollbackBaseOnSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useMutation: mocks.useMutation,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
deploymentService: {
|
||||
promote: {
|
||||
mutationOptions: () => ({
|
||||
onSuccess: mocks.promoteBaseOnSuccess,
|
||||
}),
|
||||
},
|
||||
rollback: {
|
||||
mutationOptions: () => ({
|
||||
onSuccess: mocks.rollbackBaseOnSuccess,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
},
|
||||
}))
|
||||
|
||||
function release(id: string, createdAt: string): Release {
|
||||
return {
|
||||
appInstanceId: 'app-instance-1',
|
||||
createdBy: {
|
||||
id: 'user-1',
|
||||
displayName: 'User',
|
||||
},
|
||||
description: `${id} description`,
|
||||
gateCommitId: `${id}-commit`,
|
||||
id,
|
||||
displayName: id,
|
||||
requiredSlots: [],
|
||||
source: ReleaseSource.RELEASE_SOURCE_UPLOAD,
|
||||
sourceAppId: 'source-app-1',
|
||||
createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function environment(id = 'env-1'): Environment {
|
||||
return {
|
||||
apiServer: 'https://api.example.com',
|
||||
backend: RuntimeBackend.RUNTIME_BACKEND_EXTERNAL,
|
||||
cpuCount: 2,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
description: 'Production environment',
|
||||
id,
|
||||
managedBy: 'system',
|
||||
mode: EnvironmentMode.ENVIRONMENT_MODE_ISOLATED,
|
||||
displayName: 'Production',
|
||||
namespace: 'production',
|
||||
runtimeEndpoint: 'https://runtime.example.com',
|
||||
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
|
||||
statusMessage: 'Ready',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRow(currentRelease: Release): EnvironmentDeployment {
|
||||
return {
|
||||
appInstanceId: 'app-instance-1',
|
||||
environment: environment(),
|
||||
currentRelease,
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDeployReleaseSubmission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.mutateCalls = []
|
||||
mocks.useMutation.mockImplementation((options: MutationCallbackOptions) => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn((variables: unknown, perCallOptions?: MutationCallbackOptions) => {
|
||||
mocks.mutateCalls.push({ options, perCallOptions, variables })
|
||||
options.onSuccess?.({}, variables, undefined, {})
|
||||
perCallOptions?.onSuccess?.({}, variables, undefined, {})
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
// Deployment success should close the outer drawer store while form atoms stay scoped.
|
||||
describe('Deployment success', () => {
|
||||
it('should close the parent deploy drawer from inside the scoped form store', async () => {
|
||||
const store = createStore()
|
||||
const currentRelease = release('release-current', '2026-01-01T00:00:00.000Z')
|
||||
const targetRelease = release('release-target', '2026-01-02T00:00:00.000Z')
|
||||
const config = {
|
||||
appInstanceId: 'app-instance-1',
|
||||
defaultReleaseId: targetRelease.id,
|
||||
environments: [environment()],
|
||||
releases: [targetRelease, currentRelease],
|
||||
runtimeRows: [runtimeRow(currentRelease)],
|
||||
}
|
||||
store.set(deployDrawerOpenAtom, true)
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={store}>
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[deployReadyFormConfigAtom, config],
|
||||
...deployReadyFormLocalAtoms,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</ScopeProvider>
|
||||
</JotaiProvider>
|
||||
)
|
||||
const { result } = renderHook(() => useDeployReleaseSubmission({
|
||||
deploymentCredentials: [],
|
||||
deploymentEnvVars: [],
|
||||
}), { wrapper })
|
||||
|
||||
await act(async () => {
|
||||
result.current.deployRelease()
|
||||
})
|
||||
|
||||
expect(mocks.mutateCalls).toHaveLength(1)
|
||||
expect(mocks.promoteBaseOnSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(store.get(deployDrawerOpenAtom)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,395 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
CredentialSelectionInput,
|
||||
CredentialSlot,
|
||||
Environment,
|
||||
EnvironmentDeployment,
|
||||
EnvVarInput,
|
||||
Release,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { Getter } from 'jotai'
|
||||
import type {
|
||||
EnvVarBindingSlot,
|
||||
EnvVarValues,
|
||||
EnvVarValueSelection,
|
||||
} from '../env-var-bindings'
|
||||
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
|
||||
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createDeploymentIdempotencyKey } from '../../idempotency'
|
||||
import { releaseDeploymentAction } from '../../release-action'
|
||||
import { closeDeployDrawerAtom } from '../../store'
|
||||
import { envVarBindingSlotFromContract } from '../env-var-bindings-utils'
|
||||
import {
|
||||
hasMissingRequiredRuntimeCredentialBinding,
|
||||
runtimeCredentialSlotKey,
|
||||
selectedDeploymentRuntimeCredentials,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '../runtime-credential-bindings-utils'
|
||||
|
||||
export type DeployReadyFormConfig = {
|
||||
appInstanceId: string
|
||||
environments: Environment[]
|
||||
releases: Release[]
|
||||
runtimeRows: EnvironmentDeployment[]
|
||||
defaultReleaseId?: string
|
||||
lockedEnvId?: string
|
||||
presetReleaseId?: string
|
||||
releaseEmptyLabel?: string
|
||||
}
|
||||
|
||||
export const deployReadyFormConfigAtom = atom<DeployReadyFormConfig | undefined>(undefined)
|
||||
|
||||
const selectedEnvIdAtom = atom<string | undefined>(undefined)
|
||||
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
|
||||
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
|
||||
const envVarValuesAtom = atom<EnvVarValues>({})
|
||||
const showValidationErrorsAtom = atom(false)
|
||||
export const deployReadyFormLocalAtoms = [
|
||||
selectedEnvIdAtom,
|
||||
selectedReleaseIdAtom,
|
||||
manualBindingsAtom,
|
||||
envVarValuesAtom,
|
||||
showValidationErrorsAtom,
|
||||
] as const
|
||||
|
||||
function formConfig(get: Getter) {
|
||||
const config = get(deployReadyFormConfigAtom)
|
||||
if (!config)
|
||||
throw new Error('Missing deploy ready form config.')
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
const resetDeployBindingsAtom = atom(null, (_get, set) => {
|
||||
set(manualBindingsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(showValidationErrorsAtom, false)
|
||||
})
|
||||
|
||||
const selectDeployEnvironmentAtom = atom(null, (_get, set, environmentId: string) => {
|
||||
set(selectedEnvIdAtom, environmentId)
|
||||
set(resetDeployBindingsAtom)
|
||||
})
|
||||
|
||||
const selectDeployReleaseAtom = atom(null, (_get, set, releaseId: string) => {
|
||||
set(selectedReleaseIdAtom, releaseId)
|
||||
set(resetDeployBindingsAtom)
|
||||
})
|
||||
|
||||
export const showDeployValidationErrorsAtom = atom(null, (_get, set) => {
|
||||
set(showValidationErrorsAtom, true)
|
||||
})
|
||||
|
||||
const deployPresetReleaseAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
return config.presetReleaseId ? config.releases.find(r => r.id === config.presetReleaseId) : undefined
|
||||
})
|
||||
|
||||
const deployDisplayedReleaseAtom = atom((get): Release | undefined => {
|
||||
return get(deployPresetReleaseAtom)
|
||||
})
|
||||
|
||||
const deployIsExistingReleaseAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
return Boolean(config.presetReleaseId)
|
||||
})
|
||||
|
||||
export const deploySelectedEnvironmentIdAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
return get(selectedEnvIdAtom) ?? config.lockedEnvId ?? config.environments[0]?.id
|
||||
})
|
||||
|
||||
export const deploySelectedEnvironmentAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
|
||||
return selectedEnvironmentId
|
||||
? config.environments.find(env => env.id === selectedEnvironmentId)
|
||||
: undefined
|
||||
})
|
||||
|
||||
const deploySelectedReleaseIdAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
const displayedRelease = get(deployDisplayedReleaseAtom)
|
||||
return get(selectedReleaseIdAtom) ?? displayedRelease?.id ?? config.defaultReleaseId
|
||||
})
|
||||
|
||||
const deploySelectedReleaseAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
const selectedReleaseId = get(deploySelectedReleaseIdAtom)
|
||||
return selectedReleaseId
|
||||
? config.releases.find(release => release.id === selectedReleaseId)
|
||||
: undefined
|
||||
})
|
||||
|
||||
const deployTargetReleaseAtom = atom((get) => {
|
||||
return get(deployDisplayedReleaseAtom) ?? get(deploySelectedReleaseAtom)
|
||||
})
|
||||
|
||||
export const deployTargetReleaseIdAtom = atom((get) => {
|
||||
const targetRelease = get(deployTargetReleaseAtom)
|
||||
return targetRelease?.id ?? get(deploySelectedReleaseIdAtom)
|
||||
})
|
||||
|
||||
export const deployHasSelectedEnvironmentAtom = atom((get) => {
|
||||
return Boolean(get(deploySelectedEnvironmentAtom))
|
||||
})
|
||||
|
||||
function useDeployReadyFormConfig() {
|
||||
const config = useAtomValue(deployReadyFormConfigAtom)
|
||||
if (!config)
|
||||
throw new Error('Missing deploy ready form config.')
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function useDeployReleaseField() {
|
||||
const config = useDeployReadyFormConfig()
|
||||
const displayedRelease = useAtomValue(deployDisplayedReleaseAtom)
|
||||
const isExistingRelease = useAtomValue(deployIsExistingReleaseAtom)
|
||||
const selectedReleaseId = useAtomValue(deploySelectedReleaseIdAtom)
|
||||
const selectRelease = useSetAtom(selectDeployReleaseAtom)
|
||||
|
||||
return {
|
||||
displayedRelease,
|
||||
emptyLabel: config.releaseEmptyLabel,
|
||||
isExistingRelease,
|
||||
releases: config.releases,
|
||||
selectedReleaseId,
|
||||
selectRelease,
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeployEnvironmentField() {
|
||||
const config = useDeployReadyFormConfig()
|
||||
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
|
||||
const selectEnvironment = useSetAtom(selectDeployEnvironmentAtom)
|
||||
const lockedEnv = config.lockedEnvId ? config.environments.find(e => e.id === config.lockedEnvId) : undefined
|
||||
|
||||
return {
|
||||
environments: config.environments,
|
||||
lockedEnv,
|
||||
lockedEnvId: config.lockedEnvId,
|
||||
selectedEnvironmentId,
|
||||
selectEnvironment,
|
||||
}
|
||||
}
|
||||
|
||||
export function useReleaseDeploymentOptions() {
|
||||
const hasSelectedEnvironment = useAtomValue(deployHasSelectedEnvironmentAtom)
|
||||
const releaseId = useAtomValue(deployTargetReleaseIdAtom)
|
||||
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
|
||||
const shouldLoadDeploymentOptions = Boolean(releaseId && selectedEnvironmentId && hasSelectedEnvironment)
|
||||
const deploymentOptionsQuery = useQuery({
|
||||
...consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
|
||||
input: shouldLoadDeploymentOptions && releaseId && selectedEnvironmentId
|
||||
? {
|
||||
body: {
|
||||
releaseId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
const bindingSlots = deploymentOptionsQuery.data?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
|
||||
const envVarSlots = deploymentOptionsQuery.data?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
|
||||
const bindingSlot = envVarBindingSlotFromContract(slot)
|
||||
return bindingSlot ? [bindingSlot] : []
|
||||
}) ?? []
|
||||
const deploymentOptionsLoading = deploymentOptionsQuery.isLoading || deploymentOptionsQuery.isFetching
|
||||
const isBindingOptionsLoading = Boolean(
|
||||
releaseId
|
||||
&& hasSelectedEnvironment
|
||||
&& deploymentOptionsLoading,
|
||||
)
|
||||
const hasBindingOptionsError = deploymentOptionsQuery.isError
|
||||
const isBindingOptionsReady = Boolean(
|
||||
releaseId
|
||||
&& hasSelectedEnvironment
|
||||
&& deploymentOptionsQuery.data
|
||||
&& !isBindingOptionsLoading
|
||||
&& !hasBindingOptionsError,
|
||||
)
|
||||
|
||||
return {
|
||||
bindingSlots,
|
||||
envVarSlots,
|
||||
hasBindingOptionsError,
|
||||
isBindingOptionsLoading,
|
||||
isBindingOptionsReady,
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeployBindings({
|
||||
bindingSlots,
|
||||
envVarSlots,
|
||||
}: {
|
||||
bindingSlots: CredentialSlot[]
|
||||
envVarSlots: EnvVarBindingSlot[]
|
||||
}) {
|
||||
const [manualBindings, setManualBindings] = useAtom(manualBindingsAtom)
|
||||
const [envVarValues, setEnvVarValues] = useAtom(envVarValuesAtom)
|
||||
const showValidationErrors = useAtomValue(showValidationErrorsAtom)
|
||||
const showDeploymentValidationErrors = useSetAtom(showDeployValidationErrorsAtom)
|
||||
const selectedBindings = selectedRuntimeCredentialSelections(bindingSlots, manualBindings)
|
||||
const deploymentCredentials = selectedDeploymentRuntimeCredentials(bindingSlots, selectedBindings)
|
||||
const deploymentEnvVars = envVarSlots.flatMap((slot): EnvVarInput[] => {
|
||||
const selection = envVarValues[slot.key]
|
||||
const valueSource = selection?.valueSource
|
||||
?? (slot.hasLastValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
|
||||
: slot.hasDefaultValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
|
||||
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
|
||||
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) {
|
||||
return slot.hasLastValue
|
||||
? [{ key: slot.key, valueSource }]
|
||||
: []
|
||||
}
|
||||
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) {
|
||||
return slot.hasDefaultValue
|
||||
? [{ key: slot.key, valueSource }]
|
||||
: []
|
||||
}
|
||||
|
||||
if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value))))
|
||||
return []
|
||||
|
||||
return [{
|
||||
key: slot.key,
|
||||
value: selection.value,
|
||||
valueSource,
|
||||
}]
|
||||
})
|
||||
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredRuntimeCredentialBinding(slot, selectedBindings[runtimeCredentialSlotKey(slot)]))
|
||||
const requiredEnvVarsReady = envVarSlots.every((slot) => {
|
||||
const selection = envVarValues[slot.key]
|
||||
const valueSource = selection?.valueSource
|
||||
?? (slot.hasLastValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
|
||||
: slot.hasDefaultValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
|
||||
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
|
||||
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT)
|
||||
return Boolean(slot.hasLastValue)
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT)
|
||||
return Boolean(slot.hasDefaultValue)
|
||||
if (!selection?.value)
|
||||
return false
|
||||
|
||||
return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value))
|
||||
})
|
||||
|
||||
function handleBindingChange(slot: string, value: string) {
|
||||
setManualBindings(prev => ({ ...prev, [slot]: value }))
|
||||
}
|
||||
|
||||
function handleEnvVarChange(key: string, value: EnvVarValueSelection) {
|
||||
setEnvVarValues(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
return {
|
||||
deploymentCredentials,
|
||||
deploymentEnvVars,
|
||||
handleBindingChange,
|
||||
handleEnvVarChange,
|
||||
envVarValues,
|
||||
requiredBindingsReady,
|
||||
requiredEnvVarsReady,
|
||||
selectedBindings,
|
||||
showDeploymentValidationErrors,
|
||||
showValidationErrors,
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeployReleaseSubmission({
|
||||
deploymentCredentials,
|
||||
deploymentEnvVars,
|
||||
}: {
|
||||
deploymentCredentials: CredentialSelectionInput[]
|
||||
deploymentEnvVars: EnvVarInput[]
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const config = useDeployReadyFormConfig()
|
||||
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
|
||||
const promoteRelease = useMutation(consoleQuery.enterprise.deploymentService.promote.mutationOptions())
|
||||
const rollbackRelease = useMutation(consoleQuery.enterprise.deploymentService.rollback.mutationOptions())
|
||||
const isSubmitting = promoteRelease.isPending || rollbackRelease.isPending
|
||||
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
|
||||
const targetRelease = useAtomValue(deployTargetReleaseAtom)
|
||||
const targetReleaseId = useAtomValue(deployTargetReleaseIdAtom)
|
||||
|
||||
function deployRelease() {
|
||||
if (!targetReleaseId || !selectedEnvironmentId)
|
||||
return
|
||||
|
||||
const idempotencyKey = createDeploymentIdempotencyKey()
|
||||
const currentRelease = config.runtimeRows.find(row => row.environment.id === selectedEnvironmentId)?.currentRelease
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease,
|
||||
currentRelease,
|
||||
releaseRows: config.releases,
|
||||
isExistingRelease: true,
|
||||
})
|
||||
const mutationOptions = {
|
||||
onSuccess: () => {
|
||||
closeDeployDrawer()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('deployDrawer.deployFailed'))
|
||||
},
|
||||
}
|
||||
|
||||
if (action === 'rollback') {
|
||||
rollbackRelease.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
targetReleaseId,
|
||||
idempotencyKey,
|
||||
},
|
||||
},
|
||||
mutationOptions,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
promoteRelease.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
releaseId: targetReleaseId,
|
||||
credentials: deploymentCredentials,
|
||||
envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined,
|
||||
idempotencyKey,
|
||||
},
|
||||
},
|
||||
mutationOptions,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
deployRelease,
|
||||
isSubmitting,
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ import {
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
@ -132,9 +132,10 @@ export function EditDeploymentDialog({
|
||||
const { t } = useTranslation('deployments')
|
||||
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
|
||||
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
|
||||
input: open
|
||||
? { params: { appInstanceId } }
|
||||
: skipToken,
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
enabled: open,
|
||||
}))
|
||||
const app = instanceQuery.data?.appInstance
|
||||
const formKey = app ? `${app.id}-${app.displayName}-${app.description}` : 'loading'
|
||||
@ -11,8 +11,8 @@ import {
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DeleteDeploymentDialog } from './delete-deployment-dialog'
|
||||
import { EditDeploymentDialog } from './edit-deployment-dialog'
|
||||
import { DeleteDeploymentDialog } from './delete-dialog'
|
||||
import { EditDeploymentDialog } from './edit-dialog'
|
||||
|
||||
const ACTION_TRIGGER_CLASS_NAME = cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-lg bg-components-panel-bg text-text-tertiary shadow-xs outline-hidden',
|
||||
@ -54,6 +54,7 @@ export function DeploymentActionsMenu({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
className={className}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => event.stopPropagation()}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { UnsupportedDslNode } from '../error'
|
||||
import type { UnsupportedDslNode } from '../shared/domain/error'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import type {
|
||||
import type { Getter } from 'jotai/vanilla'
|
||||
import type { EnvVarBindingSlot, EnvVarValues, EnvVarValueSelection } from '@/features/deployments/components/env-var-bindings'
|
||||
import type { RuntimeCredentialBindingSelections } from '@/features/deployments/components/runtime-credential-bindings-utils'
|
||||
import type { UnsupportedDslNode } from '@/features/deployments/error'
|
||||
import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error'
|
||||
import type { App } from '@/types/app'
|
||||
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
@ -21,22 +21,22 @@ import {
|
||||
selectedDeploymentRuntimeCredentials,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '@/features/deployments/components/runtime-credential-bindings-utils'
|
||||
import {
|
||||
DEPLOYMENT_PAGE_SIZE,
|
||||
getNextPageParamFromPagination,
|
||||
SOURCE_APPS_PAGE_SIZE,
|
||||
} from '@/features/deployments/data'
|
||||
import {
|
||||
dslAppName,
|
||||
dslEnvVarSlots,
|
||||
encodeDslContent,
|
||||
isWorkflowDsl,
|
||||
} from '@/features/deployments/dsl'
|
||||
import { environmentMatchesIdentifier } from '@/features/deployments/environment'
|
||||
import { unsupportedDslNodeError } from '@/features/deployments/error'
|
||||
import { createDeploymentIdempotencyKey } from '@/features/deployments/idempotency'
|
||||
} from '@/features/deployments/shared/domain/dsl'
|
||||
import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error'
|
||||
import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency'
|
||||
import {
|
||||
DEPLOYMENT_PAGE_SIZE,
|
||||
getNextPageParamFromPagination,
|
||||
SOURCE_APPS_PAGE_SIZE,
|
||||
} from '@/features/deployments/shared/domain/pagination'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { environmentMatchesIdentifier } from './environment'
|
||||
|
||||
export type GuideMethod = 'bindApp' | 'importDsl'
|
||||
export type GuideStep = 'source' | 'release' | 'target'
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
continueFromReleaseAtom,
|
||||
dslDefaultAppNameAtom,
|
||||
hasInstanceNameConflictAtom,
|
||||
instanceDescriptionAtom,
|
||||
instanceNameAtom,
|
||||
methodAtom,
|
||||
releaseCanGoNextAtom,
|
||||
releaseDescriptionAtom,
|
||||
releaseNameAtom,
|
||||
selectedAppAtom,
|
||||
@ -16,11 +19,30 @@ import {
|
||||
setInstanceNameAtom,
|
||||
setReleaseDescriptionAtom,
|
||||
setReleaseNameAtom,
|
||||
stepAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { StepShell } from './layout'
|
||||
|
||||
const releaseTextareaClassName = 'min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs'
|
||||
|
||||
export function DeploymentInfoSection() {
|
||||
export function ReleaseStepContent() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.release.title')}
|
||||
description={t('createGuide.release.description')}
|
||||
hideHeader
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<DeploymentInfoSection />
|
||||
<InitialReleaseSection />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentInfoSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
@ -34,7 +56,7 @@ export function DeploymentInfoSection() {
|
||||
)
|
||||
}
|
||||
|
||||
export function InitialReleaseSection() {
|
||||
function InitialReleaseSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
@ -167,3 +189,21 @@ function OptionalFieldLabel({ children, htmlFor }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseActionButtons() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const canGoNext = useAtomValue(releaseCanGoNextAtom)
|
||||
const setStep = useSetAtom(stepAtom)
|
||||
const continueFromRelease = useSetAtom(continueFromReleaseAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={() => setStep('source')}>
|
||||
{t('createGuide.actions.back')}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" disabled={!canGoNext} onClick={continueFromRelease}>
|
||||
{t('createGuide.actions.next')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
continueFromReleaseAtom,
|
||||
releaseCanGoNextAtom,
|
||||
stepAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { StepShell } from '../layout'
|
||||
import {
|
||||
DeploymentInfoSection,
|
||||
InitialReleaseSection,
|
||||
} from './fields'
|
||||
|
||||
export function ReleaseStepContent() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.release.title')}
|
||||
description={t('createGuide.release.description')}
|
||||
hideHeader
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<DeploymentInfoSection />
|
||||
<InitialReleaseSection />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseActionButtons() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const canGoNext = useAtomValue(releaseCanGoNextAtom)
|
||||
const setStep = useSetAtom(stepAtom)
|
||||
const continueFromRelease = useSetAtom(continueFromReleaseAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={() => setStep('source')}>
|
||||
{t('createGuide.actions.back')}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" disabled={!canGoNext} onClick={continueFromRelease}>
|
||||
{t('createGuide.actions.next')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -6,11 +6,11 @@ import { GuideCard, GuideFrame } from './layout'
|
||||
import {
|
||||
ReleaseActionButtons,
|
||||
ReleaseStepContent,
|
||||
} from './release-step/release-step-content'
|
||||
} from './release-step'
|
||||
import {
|
||||
SourceActionButtons,
|
||||
SourceStepContent,
|
||||
} from './source-step/source-step-content'
|
||||
} from './source-step'
|
||||
import {
|
||||
TargetBackButton,
|
||||
TargetDeployButton,
|
||||
|
||||
360
web/features/deployments/create-guide/ui/source-step.tsx
Normal file
360
web/features/deployments/create-guide/ui/source-step.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
'use client'
|
||||
|
||||
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { DeploymentStateMessage } from '@/features/deployments/components/empty-state'
|
||||
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
|
||||
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
|
||||
import {
|
||||
continueFromSourceAtom,
|
||||
dslFileAtom,
|
||||
dslReadErrorAtom,
|
||||
dslUnsupportedModeAtom,
|
||||
effectiveSelectedAppAtom,
|
||||
isReadingDslAtom,
|
||||
methodAtom,
|
||||
selectDslFileAtom,
|
||||
selectMethodAtom,
|
||||
selectSourceAppAtom,
|
||||
setSourceSearchTextAtom,
|
||||
sourceAppsQueryAtom,
|
||||
sourceCanGoNextAtom,
|
||||
sourceSearchTextAtom,
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { StepShell } from './layout'
|
||||
|
||||
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
|
||||
|
||||
export function SourceStepContent() {
|
||||
const method = useAtomValue(methodAtom)
|
||||
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<SourceMethodSection />
|
||||
{method === 'bindApp' && (
|
||||
<SourceAppSelectionSection />
|
||||
)}
|
||||
{method === 'importDsl' && (
|
||||
<DslUploadSection />
|
||||
)}
|
||||
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceMethodSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const method = useAtomValue(methodAtom)
|
||||
const selectMethod = useSetAtom(selectMethodAtom)
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.steps.method')}
|
||||
description={t('createGuide.method.description')}
|
||||
descriptionClassName="lg:hidden"
|
||||
hideHeader
|
||||
>
|
||||
<RadioGroup<GuideMethod>
|
||||
value={method}
|
||||
onValueChange={selectMethod}
|
||||
className="flex flex-col items-stretch gap-2 sm:flex-row"
|
||||
>
|
||||
<SourceMethodCard
|
||||
value="bindApp"
|
||||
icon="i-ri-stack-line"
|
||||
title={t('createGuide.methods.bindApp.title')}
|
||||
description={t('createGuide.methods.bindApp.description')}
|
||||
/>
|
||||
<SourceMethodCard
|
||||
value="importDsl"
|
||||
icon="i-ri-file-code-line"
|
||||
title={t('createGuide.methods.importDsl.title')}
|
||||
description={t('createGuide.methods.importDsl.description')}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceMethodCard({ value, icon, title, description, badge }: {
|
||||
value: GuideMethod
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
badge?: string
|
||||
}) {
|
||||
return (
|
||||
<RadioRoot<GuideMethod>
|
||||
value={value}
|
||||
variant="unstyled"
|
||||
className={cn(
|
||||
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
|
||||
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
|
||||
text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2
|
||||
focus-visible:ring-state-accent-solid sm:w-[240px]`,
|
||||
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
|
||||
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
|
||||
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
|
||||
{badge && (
|
||||
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-start gap-1">
|
||||
<TitleTooltip content={description}>
|
||||
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</span>
|
||||
</RadioRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppSelectionSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.source.title')}
|
||||
description={t('createGuide.source.description')}
|
||||
descriptionClassName="lg:hidden"
|
||||
hideHeader
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<SourceSearchInput />
|
||||
<SourceAppList />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceSearchInput() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const sourceSearchText = useAtomValue(sourceSearchTextAtom)
|
||||
const setSourceSearchText = useSetAtom(setSourceSearchTextAtom)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
|
||||
<Input
|
||||
id="create-guide-source-search"
|
||||
aria-label={t('createGuide.source.sourceApp')}
|
||||
value={sourceSearchText}
|
||||
onChange={event => setSourceSearchText(event.target.value)}
|
||||
placeholder={t('createGuide.source.searchPlaceholder')}
|
||||
className="h-9 pr-8 pl-8"
|
||||
/>
|
||||
{sourceSearchText && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('createGuide.source.clearSearch')}
|
||||
onClick={() => setSourceSearchText('')}
|
||||
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
|
||||
>
|
||||
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppList() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const selectSourceApp = useSetAtom(selectSourceAppAtom)
|
||||
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
|
||||
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
|
||||
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
|
||||
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
|
||||
{sourceAppsLoading
|
||||
? <SourceAppSkeleton />
|
||||
: sourceApps.length === 0
|
||||
? (
|
||||
<DeploymentStateMessage variant="embedded">
|
||||
{t('createGuide.source.empty')}
|
||||
</DeploymentStateMessage>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{sourceApps.map(app => (
|
||||
<SourceAppOption
|
||||
key={app.id}
|
||||
app={app}
|
||||
selected={effectiveSelectedApp?.id === app.id}
|
||||
onSelect={() => selectSourceApp(app)}
|
||||
/>
|
||||
))}
|
||||
{sourceAppsQuery.hasNextPage && (
|
||||
<div className="flex justify-center border-t border-divider-subtle px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
disabled={sourceAppsQuery.isFetchingNextPage}
|
||||
onClick={() => {
|
||||
void sourceAppsQuery.fetchNextPage()
|
||||
}}
|
||||
>
|
||||
{sourceAppsQuery.isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-divider-subtle">
|
||||
{sourceAppSkeletonKeys.map(key => (
|
||||
<SkeletonRow key={key} className="h-14 px-3 py-2">
|
||||
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
|
||||
<div className="flex min-w-0 grow flex-col gap-1">
|
||||
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppOption({ app, onSelect, selected }: {
|
||||
app: WorkflowSourceApp
|
||||
onSelect: () => void
|
||||
selected: boolean
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
|
||||
selected
|
||||
? 'bg-state-accent-hover hover:bg-state-accent-hover'
|
||||
: 'bg-background-default hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<AppIcon
|
||||
className="shrink-0"
|
||||
size="xs"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<span className="flex min-w-0 grow">
|
||||
<span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span>
|
||||
</span>
|
||||
<input
|
||||
type="radio"
|
||||
name="source-app"
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
||||
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="i-ri-check-line size-4" />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function DslUploadSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const dslFile = useAtomValue(dslFileAtom)
|
||||
const selectDslFile = useSetAtom(selectDslFileAtom)
|
||||
|
||||
return (
|
||||
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
|
||||
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Uploader
|
||||
className="mt-0"
|
||||
file={dslFile}
|
||||
updateFile={selectDslFile}
|
||||
/>
|
||||
<DslReadStatus />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DslReadStatus() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const isReadingDsl = useAtomValue(isReadingDslAtom)
|
||||
const dslReadError = useAtomValue(dslReadErrorAtom)
|
||||
const dslUnsupportedMode = useAtomValue(dslUnsupportedModeAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isReadingDsl && (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('createGuide.dsl.reading')}
|
||||
</div>
|
||||
)}
|
||||
{dslReadError && (
|
||||
<div className="system-xs-regular text-text-destructive">
|
||||
{t('createGuide.dsl.readFailed')}
|
||||
</div>
|
||||
)}
|
||||
{dslUnsupportedMode && (
|
||||
<div role="alert" className="system-xs-regular text-text-destructive">
|
||||
{t('createGuide.dsl.unsupportedMode')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceActionButtons() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const canGoNext = useAtomValue(sourceCanGoNextAtom)
|
||||
const continueFromSource = useSetAtom(continueFromSourceAtom)
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canGoNext}
|
||||
onClick={() => continueFromSource({
|
||||
defaultDslAppName: t('createGuide.dsl.defaultAppName'),
|
||||
defaultReleaseName: t('createGuide.release.defaultName'),
|
||||
})}
|
||||
>
|
||||
{t('createGuide.actions.next')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
|
||||
import {
|
||||
dslFileAtom,
|
||||
dslReadErrorAtom,
|
||||
dslUnsupportedModeAtom,
|
||||
isReadingDslAtom,
|
||||
selectDslFileAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { StepShell } from '../layout'
|
||||
|
||||
export function DslUploadSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const dslFile = useAtomValue(dslFileAtom)
|
||||
const selectDslFile = useSetAtom(selectDslFileAtom)
|
||||
|
||||
return (
|
||||
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
|
||||
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Uploader
|
||||
className="mt-0"
|
||||
file={dslFile}
|
||||
updateFile={selectDslFile}
|
||||
/>
|
||||
<DslReadStatus />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DslReadStatus() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const isReadingDsl = useAtomValue(isReadingDslAtom)
|
||||
const dslReadError = useAtomValue(dslReadErrorAtom)
|
||||
const dslUnsupportedMode = useAtomValue(dslUnsupportedModeAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isReadingDsl && (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('createGuide.dsl.reading')}
|
||||
</div>
|
||||
)}
|
||||
{dslReadError && (
|
||||
<div className="system-xs-regular text-text-destructive">
|
||||
{t('createGuide.dsl.readFailed')}
|
||||
</div>
|
||||
)}
|
||||
{dslUnsupportedMode && (
|
||||
<div role="alert" className="system-xs-regular text-text-destructive">
|
||||
{t('createGuide.dsl.unsupportedMode')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,179 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkflowSourceApp } from '@/features/deployments/create-guide/state'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { DeploymentStateMessage } from '@/features/deployments/components/empty-state'
|
||||
import {
|
||||
effectiveSelectedAppAtom,
|
||||
selectSourceAppAtom,
|
||||
setSourceSearchTextAtom,
|
||||
sourceAppsQueryAtom,
|
||||
sourceSearchTextAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { StepShell } from '../layout'
|
||||
|
||||
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
|
||||
|
||||
export function SourceAppSelectionSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.source.title')}
|
||||
description={t('createGuide.source.description')}
|
||||
descriptionClassName="lg:hidden"
|
||||
hideHeader
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<SourceSearchInput />
|
||||
<SourceAppList />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceSearchInput() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const sourceSearchText = useAtomValue(sourceSearchTextAtom)
|
||||
const setSourceSearchText = useSetAtom(setSourceSearchTextAtom)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
|
||||
<Input
|
||||
id="create-guide-source-search"
|
||||
aria-label={t('createGuide.source.sourceApp')}
|
||||
value={sourceSearchText}
|
||||
onChange={event => setSourceSearchText(event.target.value)}
|
||||
placeholder={t('createGuide.source.searchPlaceholder')}
|
||||
className="h-9 pr-8 pl-8"
|
||||
/>
|
||||
{sourceSearchText && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('createGuide.source.clearSearch')}
|
||||
onClick={() => setSourceSearchText('')}
|
||||
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
|
||||
>
|
||||
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppList() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const selectSourceApp = useSetAtom(selectSourceAppAtom)
|
||||
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
|
||||
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
|
||||
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
|
||||
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
|
||||
{sourceAppsLoading
|
||||
? <SourceAppSkeleton />
|
||||
: sourceApps.length === 0
|
||||
? (
|
||||
<DeploymentStateMessage variant="embedded">
|
||||
{t('createGuide.source.empty')}
|
||||
</DeploymentStateMessage>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{sourceApps.map(app => (
|
||||
<SourceAppOption
|
||||
key={app.id}
|
||||
app={app}
|
||||
selected={effectiveSelectedApp?.id === app.id}
|
||||
onSelect={() => selectSourceApp(app)}
|
||||
/>
|
||||
))}
|
||||
{sourceAppsQuery.hasNextPage && (
|
||||
<div className="flex justify-center border-t border-divider-subtle px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
disabled={sourceAppsQuery.isFetchingNextPage}
|
||||
onClick={() => {
|
||||
void sourceAppsQuery.fetchNextPage()
|
||||
}}
|
||||
>
|
||||
{sourceAppsQuery.isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-divider-subtle">
|
||||
{sourceAppSkeletonKeys.map(key => (
|
||||
<SkeletonRow key={key} className="h-14 px-3 py-2">
|
||||
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
|
||||
<div className="flex min-w-0 grow flex-col gap-1">
|
||||
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppOption({ app, onSelect, selected }: {
|
||||
app: WorkflowSourceApp
|
||||
onSelect: () => void
|
||||
selected: boolean
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
|
||||
selected
|
||||
? 'bg-state-accent-hover hover:bg-state-accent-hover'
|
||||
: 'bg-background-default hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<AppIcon
|
||||
className="shrink-0"
|
||||
size="xs"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<span className="flex min-w-0 grow">
|
||||
<span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span>
|
||||
</span>
|
||||
<input
|
||||
type="radio"
|
||||
name="source-app"
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
||||
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="i-ri-check-line size-4" />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { GuideMethod } from '@/features/deployments/create-guide/state'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
|
||||
import { methodAtom, selectMethodAtom } from '@/features/deployments/create-guide/state'
|
||||
import { StepShell } from '../layout'
|
||||
|
||||
function SourceMethodCard({ value, icon, title, description, badge }: {
|
||||
value: GuideMethod
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
badge?: string
|
||||
}) {
|
||||
return (
|
||||
<RadioRoot<GuideMethod>
|
||||
value={value}
|
||||
variant="unstyled"
|
||||
className={cn(
|
||||
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
|
||||
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
|
||||
text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2
|
||||
focus-visible:ring-state-accent-solid sm:w-[240px]`,
|
||||
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
|
||||
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
|
||||
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
|
||||
{badge && (
|
||||
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-start gap-1">
|
||||
<TitleTooltip content={description}>
|
||||
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</span>
|
||||
</RadioRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceMethodSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const method = useAtomValue(methodAtom)
|
||||
const selectMethod = useSetAtom(selectMethodAtom)
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.steps.method')}
|
||||
description={t('createGuide.method.description')}
|
||||
descriptionClassName="lg:hidden"
|
||||
hideHeader
|
||||
>
|
||||
<RadioGroup<GuideMethod>
|
||||
value={method}
|
||||
onValueChange={selectMethod}
|
||||
className="flex flex-col items-stretch gap-2 sm:flex-row"
|
||||
>
|
||||
<SourceMethodCard
|
||||
value="bindApp"
|
||||
icon="i-ri-stack-line"
|
||||
title={t('createGuide.methods.bindApp.title')}
|
||||
description={t('createGuide.methods.bindApp.description')}
|
||||
/>
|
||||
<SourceMethodCard
|
||||
value="importDsl"
|
||||
icon="i-ri-file-code-line"
|
||||
title={t('createGuide.methods.importDsl.title')}
|
||||
description={t('createGuide.methods.importDsl.description')}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
|
||||
import {
|
||||
continueFromSourceAtom,
|
||||
methodAtom,
|
||||
sourceCanGoNextAtom,
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { DslUploadSection } from './dsl-upload-section'
|
||||
import { SourceAppSelectionSection } from './source-app-selection-section'
|
||||
import { SourceMethodSection } from './source-method-section'
|
||||
|
||||
export function SourceStepContent() {
|
||||
const method = useAtomValue(methodAtom)
|
||||
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<SourceMethodSection />
|
||||
{method === 'bindApp' && (
|
||||
<SourceAppSelectionSection />
|
||||
)}
|
||||
{method === 'importDsl' && (
|
||||
<DslUploadSection />
|
||||
)}
|
||||
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceActionButtons() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const canGoNext = useAtomValue(sourceCanGoNextAtom)
|
||||
const continueFromSource = useSetAtom(continueFromSourceAtom)
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canGoNext}
|
||||
onClick={() => continueFromSource({
|
||||
defaultDslAppName: t('createGuide.dsl.defaultAppName'),
|
||||
defaultReleaseName: t('createGuide.release.defaultName'),
|
||||
})}
|
||||
>
|
||||
{t('createGuide.actions.next')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
356
web/features/deployments/create-guide/ui/target-step.tsx
Normal file
356
web/features/deployments/create-guide/ui/target-step.tsx
Normal file
@ -0,0 +1,356 @@
|
||||
'use client'
|
||||
|
||||
import type { Environment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import {
|
||||
EnvVarBindingsPanel,
|
||||
} from '@/features/deployments/components/env-var-bindings'
|
||||
import {
|
||||
RuntimeCredentialBindingsPanel,
|
||||
} from '@/features/deployments/components/runtime-credential-bindings'
|
||||
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
|
||||
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
|
||||
import {
|
||||
canDeployAtom,
|
||||
canSkipDeploymentAtom,
|
||||
createDeploymentGuideSubmissionAtom,
|
||||
CreateDeploymentGuideSubmissionBlockedError,
|
||||
deployableEnvironmentsAtom,
|
||||
deployableEnvironmentsQueryAtom,
|
||||
deploymentOptionsQueryAtom,
|
||||
deploymentTargetBindingSelectionsAtom,
|
||||
deploymentTargetBindingSlotsAtom,
|
||||
deploymentTargetEnvVarSlotsAtom,
|
||||
effectiveSelectedEnvironmentIdAtom,
|
||||
envVarValuesAtom,
|
||||
isCreatingReleaseOnlyAtom,
|
||||
isSubmittingDeploymentGuideAtom,
|
||||
selectBindingAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
setEnvVarAtom,
|
||||
stepAtom,
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { deploymentErrorMessage } from '@/features/deployments/shared/domain/error'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { StepShell } from './layout'
|
||||
|
||||
const targetEnvironmentSkeletonKeys = ['first-target-environment', 'second-target-environment']
|
||||
const targetBindingSkeletonKeys = ['first-target-binding', 'second-target-binding']
|
||||
|
||||
export function TargetStepContent() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.target.title')}
|
||||
description={t('createGuide.target.description')}
|
||||
hideHeader
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<TargetEnvironmentSection />
|
||||
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
|
||||
<TargetBindingSection />
|
||||
<TargetEnvVarSection />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetEnvironmentSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const environmentsQuery = useAtomValue(deployableEnvironmentsQueryAtom)
|
||||
const environments = useAtomValue(deployableEnvironmentsAtom)
|
||||
const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom)
|
||||
const isEnvironmentError = environmentsQuery.isError
|
||||
const isEnvironmentLoading = environmentsQuery.isLoading || (environmentsQuery.isFetching && !environmentsQuery.data)
|
||||
const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom)
|
||||
const hasEnvironmentOptions = environments.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.environment')}</div>
|
||||
{hasEnvironmentOptions
|
||||
? (
|
||||
<RadioGroup<string>
|
||||
value={effectiveSelectedEnvironmentId}
|
||||
onValueChange={selectEnvironment}
|
||||
className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2"
|
||||
>
|
||||
{environments.map(environment => (
|
||||
<EnvironmentOptionRow
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
: isEnvironmentLoading
|
||||
? <TargetEnvironmentSkeleton />
|
||||
: (
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
|
||||
{isEnvironmentError
|
||||
? t('createGuide.target.loadEnvironmentsFailed')
|
||||
: t('createGuide.target.noEnvironmentOptions')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentOptionRow({ environment }: {
|
||||
environment: Environment
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const summary = environment.description.trim() || `${t(`mode.${environment.mode}`)} · ${t(`backend.${environment.backend}`)}`
|
||||
|
||||
return (
|
||||
<RadioRoot<string>
|
||||
value={environment.id}
|
||||
variant="unstyled"
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-center gap-3 rounded-xl border p-3 outline-hidden',
|
||||
'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-checked:border-state-accent-solid data-checked:bg-state-accent-hover data-checked:shadow-xs',
|
||||
)}
|
||||
>
|
||||
<RadioControl />
|
||||
<span className="flex min-w-0 grow flex-col gap-1">
|
||||
<span className="truncate system-sm-semibold text-text-primary group-data-checked:text-text-accent">{environment.displayName}</span>
|
||||
<TitleTooltip content={summary}>
|
||||
<span className="line-clamp-1 system-xs-regular text-text-tertiary group-data-checked:text-text-secondary">
|
||||
{summary}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</span>
|
||||
</RadioRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetBindingSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
|
||||
const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom)
|
||||
const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom)
|
||||
const isBindingError = deploymentOptionsQuery.isError
|
||||
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
|
||||
const selectBinding = useSetAtom(selectBindingAtom)
|
||||
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
|
||||
const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0)
|
||||
|
||||
if (!shouldRender)
|
||||
return null
|
||||
|
||||
if (isBindingLoading || isBindingError) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.bindings')}</div>
|
||||
<span className="system-xs-regular text-text-tertiary">{t('createGuide.target.bindingHint')}</span>
|
||||
</div>
|
||||
{isBindingLoading
|
||||
? <TargetBindingSkeleton />
|
||||
: (
|
||||
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
|
||||
{t('createGuide.target.loadBindingsFailed')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RuntimeCredentialBindingsPanel
|
||||
slots={bindingSlots}
|
||||
selections={bindingSelections}
|
||||
title={t('createGuide.target.bindings')}
|
||||
hint={t('createGuide.target.bindingHint')}
|
||||
noBindingRequiredLabel={t('createGuide.target.noBindingRequired')}
|
||||
noCredentialCandidatesLabel={t('createGuide.target.noCredentialCandidates')}
|
||||
selectCredentialLabel={t('createGuide.target.selectCredential')}
|
||||
missingRequiredLabel={t('createGuide.target.missingRequiredBinding')}
|
||||
bindingCountLabel={t('createGuide.target.bindingCount', { count: bindingSlots.length })}
|
||||
onChange={selectBinding}
|
||||
listScrollable={false}
|
||||
className="border-components-option-card-option-border bg-components-option-card-option-bg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetEnvVarSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const setEnvVar = useSetAtom(setEnvVarAtom)
|
||||
const envVarValues = useAtomValue(envVarValuesAtom)
|
||||
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
|
||||
const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom)
|
||||
const isBindingError = deploymentOptionsQuery.isError
|
||||
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
|
||||
|
||||
if (isBindingLoading || isBindingError)
|
||||
return null
|
||||
|
||||
return (
|
||||
<EnvVarBindingsPanel
|
||||
slots={envVarSlots}
|
||||
values={envVarValues}
|
||||
title={t('createGuide.target.envVars')}
|
||||
hint={t('createGuide.target.envVarHint')}
|
||||
envVarPlaceholder={t('createGuide.target.envVarPlaceholder')}
|
||||
literalSourceLabel={t('createGuide.target.envVarSource.literal')}
|
||||
defaultSourceLabel={t('createGuide.target.envVarSource.default')}
|
||||
lastDeploymentSourceLabel={t('createGuide.target.envVarSource.lastDeployment')}
|
||||
valueTypeLabels={{
|
||||
string: t('createGuide.target.envVarType.string'),
|
||||
number: t('createGuide.target.envVarType.number'),
|
||||
secret: t('createGuide.target.envVarType.secret'),
|
||||
}}
|
||||
sourceAriaLabel={key => t('createGuide.target.envVarSource.ariaLabel', { key })}
|
||||
envVarCountLabel={t('createGuide.target.envVarCount', { count: envVarSlots.length })}
|
||||
onChange={setEnvVar}
|
||||
listScrollable={false}
|
||||
className="border-components-option-card-option-border bg-components-option-card-option-bg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetEnvironmentSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{targetEnvironmentSkeletonKeys.map(key => (
|
||||
<SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3">
|
||||
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
|
||||
<div className="flex min-w-0 grow flex-col gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-3.5 w-1/2 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-2/3 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetBindingSkeleton() {
|
||||
return (
|
||||
<div className="border-t border-divider-subtle">
|
||||
{targetBindingSkeletonKeys.map(key => (
|
||||
<SkeletonRow key={key} className="h-15 px-3 py-3">
|
||||
<div className="flex min-w-0 grow flex-col gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-8 w-48 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetBackButton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const setStep = useSetAtom(stepAtom)
|
||||
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
|
||||
|
||||
return (
|
||||
<Button type="button" variant="secondary" onClick={() => setStep('release')} disabled={isSubmitting}>
|
||||
{t('createGuide.actions.back')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetSkipDeploymentButton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const canSkipDeployment = useAtomValue(canSkipDeploymentAtom)
|
||||
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
|
||||
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
|
||||
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
|
||||
const label = isSkippingDeployment
|
||||
? t('createGuide.actions.creating')
|
||||
: t('createGuide.actions.skipDeploy')
|
||||
|
||||
async function handleSkipDeployment() {
|
||||
if (!canSkipDeployment)
|
||||
return
|
||||
|
||||
try {
|
||||
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: false })
|
||||
if (appInstanceId)
|
||||
router.push(`/deployments/${appInstanceId}/overview`)
|
||||
}
|
||||
catch (error) {
|
||||
await showSubmissionError({
|
||||
error,
|
||||
fallbackMessage: t('createGuide.errors.createReleaseFailed'),
|
||||
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant="secondary" disabled={!canSkipDeployment || isSubmitting} onClick={handleSkipDeployment}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetDeployButton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const canDeploy = useAtomValue(canDeployAtom)
|
||||
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
|
||||
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
|
||||
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
|
||||
const label = isSubmitting && !isSkippingDeployment
|
||||
? t('createGuide.actions.deploying')
|
||||
: t('createGuide.actions.createAndDeploy')
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!canDeploy)
|
||||
return
|
||||
|
||||
try {
|
||||
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: true })
|
||||
if (appInstanceId)
|
||||
router.push(`/deployments/${appInstanceId}/overview`)
|
||||
}
|
||||
catch (error) {
|
||||
await showSubmissionError({
|
||||
error,
|
||||
fallbackMessage: t('createGuide.errors.deployFailed'),
|
||||
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant="primary" disabled={!canDeploy || isSubmitting} onClick={handleDeploy}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
async function showSubmissionError({
|
||||
error,
|
||||
fallbackMessage,
|
||||
unsupportedDslModeMessage,
|
||||
}: {
|
||||
error: unknown
|
||||
fallbackMessage: string
|
||||
unsupportedDslModeMessage: string
|
||||
}) {
|
||||
if (error instanceof CreateDeploymentGuideSubmissionBlockedError) {
|
||||
toast.error(error.reason === 'unsupportedDslMode' ? unsupportedDslModeMessage : fallbackMessage)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(await deploymentErrorMessage(error) || fallbackMessage)
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RuntimeCredentialBindingsPanel,
|
||||
} from '@/features/deployments/components/runtime-credential-bindings'
|
||||
import {
|
||||
deploymentOptionsQueryAtom,
|
||||
deploymentTargetBindingSelectionsAtom,
|
||||
deploymentTargetBindingSlotsAtom,
|
||||
selectBindingAtom,
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { TargetBindingSkeleton } from './skeletons'
|
||||
|
||||
export function TargetBindingSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
|
||||
const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom)
|
||||
const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom)
|
||||
const isBindingError = deploymentOptionsQuery.isError
|
||||
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
|
||||
const selectBinding = useSetAtom(selectBindingAtom)
|
||||
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
|
||||
const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0)
|
||||
|
||||
if (!shouldRender)
|
||||
return null
|
||||
|
||||
if (isBindingLoading || isBindingError) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.bindings')}</div>
|
||||
<span className="system-xs-regular text-text-tertiary">{t('createGuide.target.bindingHint')}</span>
|
||||
</div>
|
||||
{isBindingLoading
|
||||
? <TargetBindingSkeleton />
|
||||
: (
|
||||
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
|
||||
{t('createGuide.target.loadBindingsFailed')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RuntimeCredentialBindingsPanel
|
||||
slots={bindingSlots}
|
||||
selections={bindingSelections}
|
||||
title={t('createGuide.target.bindings')}
|
||||
hint={t('createGuide.target.bindingHint')}
|
||||
noBindingRequiredLabel={t('createGuide.target.noBindingRequired')}
|
||||
noCredentialCandidatesLabel={t('createGuide.target.noCredentialCandidates')}
|
||||
selectCredentialLabel={t('createGuide.target.selectCredential')}
|
||||
missingRequiredLabel={t('createGuide.target.missingRequiredBinding')}
|
||||
bindingCountLabel={t('createGuide.target.bindingCount', { count: bindingSlots.length })}
|
||||
onChange={selectBinding}
|
||||
listScrollable={false}
|
||||
className="border-components-option-card-option-border bg-components-option-card-option-bg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
EnvVarBindingsPanel,
|
||||
} from '@/features/deployments/components/env-var-bindings'
|
||||
import {
|
||||
deploymentOptionsQueryAtom,
|
||||
deploymentTargetEnvVarSlotsAtom,
|
||||
envVarValuesAtom,
|
||||
setEnvVarAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
|
||||
export function TargetEnvVarSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const setEnvVar = useSetAtom(setEnvVarAtom)
|
||||
const envVarValues = useAtomValue(envVarValuesAtom)
|
||||
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
|
||||
const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom)
|
||||
const isBindingError = deploymentOptionsQuery.isError
|
||||
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
|
||||
|
||||
if (isBindingLoading || isBindingError)
|
||||
return null
|
||||
|
||||
return (
|
||||
<EnvVarBindingsPanel
|
||||
slots={envVarSlots}
|
||||
values={envVarValues}
|
||||
title={t('createGuide.target.envVars')}
|
||||
hint={t('createGuide.target.envVarHint')}
|
||||
envVarPlaceholder={t('createGuide.target.envVarPlaceholder')}
|
||||
literalSourceLabel={t('createGuide.target.envVarSource.literal')}
|
||||
defaultSourceLabel={t('createGuide.target.envVarSource.default')}
|
||||
lastDeploymentSourceLabel={t('createGuide.target.envVarSource.lastDeployment')}
|
||||
valueTypeLabels={{
|
||||
string: t('createGuide.target.envVarType.string'),
|
||||
number: t('createGuide.target.envVarType.number'),
|
||||
secret: t('createGuide.target.envVarType.secret'),
|
||||
}}
|
||||
sourceAriaLabel={key => t('createGuide.target.envVarSource.ariaLabel', { key })}
|
||||
envVarCountLabel={t('createGuide.target.envVarCount', { count: envVarSlots.length })}
|
||||
onChange={setEnvVar}
|
||||
listScrollable={false}
|
||||
className="border-components-option-card-option-border bg-components-option-card-option-bg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Environment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
|
||||
import {
|
||||
deployableEnvironmentsAtom,
|
||||
deployableEnvironmentsQueryAtom,
|
||||
effectiveSelectedEnvironmentIdAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { TargetEnvironmentSkeleton } from './skeletons'
|
||||
|
||||
function EnvironmentOptionRow({ environment }: {
|
||||
environment: Environment
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const summary = environment.description.trim() || `${t(`mode.${environment.mode}`)} · ${t(`backend.${environment.backend}`)}`
|
||||
|
||||
return (
|
||||
<RadioRoot<string>
|
||||
value={environment.id}
|
||||
variant="unstyled"
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-center gap-3 rounded-xl border p-3 outline-hidden',
|
||||
'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-checked:border-state-accent-solid data-checked:bg-state-accent-hover data-checked:shadow-xs',
|
||||
)}
|
||||
>
|
||||
<RadioControl />
|
||||
<span className="flex min-w-0 grow flex-col gap-1">
|
||||
<span className="truncate system-sm-semibold text-text-primary group-data-checked:text-text-accent">{environment.displayName}</span>
|
||||
<TitleTooltip content={summary}>
|
||||
<span className="line-clamp-1 system-xs-regular text-text-tertiary group-data-checked:text-text-secondary">
|
||||
{summary}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</span>
|
||||
</RadioRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetEnvironmentSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const environmentsQuery = useAtomValue(deployableEnvironmentsQueryAtom)
|
||||
const environments = useAtomValue(deployableEnvironmentsAtom)
|
||||
const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom)
|
||||
const isEnvironmentError = environmentsQuery.isError
|
||||
const isEnvironmentLoading = environmentsQuery.isLoading || (environmentsQuery.isFetching && !environmentsQuery.data)
|
||||
const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom)
|
||||
const hasEnvironmentOptions = environments.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.environment')}</div>
|
||||
{hasEnvironmentOptions
|
||||
? (
|
||||
<RadioGroup<string>
|
||||
value={effectiveSelectedEnvironmentId}
|
||||
onValueChange={selectEnvironment}
|
||||
className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2"
|
||||
>
|
||||
{environments.map(environment => (
|
||||
<EnvironmentOptionRow
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
: isEnvironmentLoading
|
||||
? <TargetEnvironmentSkeleton />
|
||||
: (
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
|
||||
{isEnvironmentError
|
||||
? t('createGuide.target.loadEnvironmentsFailed')
|
||||
: t('createGuide.target.noEnvironmentOptions')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,144 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
|
||||
import {
|
||||
canDeployAtom,
|
||||
canSkipDeploymentAtom,
|
||||
createDeploymentGuideSubmissionAtom,
|
||||
CreateDeploymentGuideSubmissionBlockedError,
|
||||
isCreatingReleaseOnlyAtom,
|
||||
isSubmittingDeploymentGuideAtom,
|
||||
stepAtom,
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { deploymentErrorMessage } from '@/features/deployments/error'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { StepShell } from '../layout'
|
||||
import { TargetBindingSection } from './binding-section'
|
||||
import { TargetEnvVarSection } from './env-var-section'
|
||||
import { TargetEnvironmentSection } from './environment-section'
|
||||
|
||||
export function TargetStepContent() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
|
||||
|
||||
return (
|
||||
<StepShell
|
||||
title={t('createGuide.target.title')}
|
||||
description={t('createGuide.target.description')}
|
||||
hideHeader
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<TargetEnvironmentSection />
|
||||
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
|
||||
<TargetBindingSection />
|
||||
<TargetEnvVarSection />
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetBackButton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const setStep = useSetAtom(stepAtom)
|
||||
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
|
||||
|
||||
return (
|
||||
<Button type="button" variant="secondary" onClick={() => setStep('release')} disabled={isSubmitting}>
|
||||
{t('createGuide.actions.back')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetSkipDeploymentButton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const canSkipDeployment = useAtomValue(canSkipDeploymentAtom)
|
||||
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
|
||||
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
|
||||
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
|
||||
const label = isSkippingDeployment
|
||||
? t('createGuide.actions.creating')
|
||||
: t('createGuide.actions.skipDeploy')
|
||||
|
||||
async function handleSkipDeployment() {
|
||||
if (!canSkipDeployment)
|
||||
return
|
||||
|
||||
try {
|
||||
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: false })
|
||||
if (appInstanceId)
|
||||
router.push(`/deployments/${appInstanceId}/overview`)
|
||||
}
|
||||
catch (error) {
|
||||
await showSubmissionError({
|
||||
error,
|
||||
fallbackMessage: t('createGuide.errors.createReleaseFailed'),
|
||||
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant="secondary" disabled={!canSkipDeployment || isSubmitting} onClick={handleSkipDeployment}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetDeployButton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const canDeploy = useAtomValue(canDeployAtom)
|
||||
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
|
||||
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
|
||||
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
|
||||
const label = isSubmitting && !isSkippingDeployment
|
||||
? t('createGuide.actions.deploying')
|
||||
: t('createGuide.actions.createAndDeploy')
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!canDeploy)
|
||||
return
|
||||
|
||||
try {
|
||||
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: true })
|
||||
if (appInstanceId)
|
||||
router.push(`/deployments/${appInstanceId}/overview`)
|
||||
}
|
||||
catch (error) {
|
||||
await showSubmissionError({
|
||||
error,
|
||||
fallbackMessage: t('createGuide.errors.deployFailed'),
|
||||
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant="primary" disabled={!canDeploy || isSubmitting} onClick={handleDeploy}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
async function showSubmissionError({
|
||||
error,
|
||||
fallbackMessage,
|
||||
unsupportedDslModeMessage,
|
||||
}: {
|
||||
error: unknown
|
||||
fallbackMessage: string
|
||||
unsupportedDslModeMessage: string
|
||||
}) {
|
||||
if (error instanceof CreateDeploymentGuideSubmissionBlockedError) {
|
||||
toast.error(error.reason === 'unsupportedDslMode' ? unsupportedDslModeMessage : fallbackMessage)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(await deploymentErrorMessage(error) || fallbackMessage)
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
|
||||
const targetEnvironmentSkeletonKeys = ['first-target-environment', 'second-target-environment']
|
||||
const targetBindingSkeletonKeys = ['first-target-binding', 'second-target-binding']
|
||||
|
||||
export function TargetEnvironmentSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{targetEnvironmentSkeletonKeys.map(key => (
|
||||
<SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3">
|
||||
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
|
||||
<div className="flex min-w-0 grow flex-col gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-3.5 w-1/2 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-2/3 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetBindingSkeleton() {
|
||||
return (
|
||||
<div className="border-t border-divider-subtle">
|
||||
{targetBindingSkeletonKeys.map(key => (
|
||||
<SkeletonRow key={key} className="h-15 px-3 py-3">
|
||||
<div className="flex min-w-0 grow flex-col gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-8 w-48 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -9,8 +9,8 @@ import {
|
||||
createReleaseConfigAtom,
|
||||
createReleaseLocalAtoms,
|
||||
openCreateReleaseDialogAtom,
|
||||
} from './atoms'
|
||||
import { CreateReleaseDialog } from './dialog'
|
||||
} from './state'
|
||||
import { CreateReleaseDialog } from './ui/dialog'
|
||||
|
||||
function CreateReleaseTrigger({
|
||||
variant,
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { UnsupportedDslNode } from '../../error'
|
||||
import type { UnsupportedDslNode } from '../../shared/domain/error'
|
||||
import type { CreateReleaseForm } from './use-create-release-form'
|
||||
import { atom, useAtomValue } from 'jotai'
|
||||
import { encodeDslContent, isWorkflowDsl } from '../../dsl'
|
||||
import { encodeDslContent, isWorkflowDsl } from '../../shared/domain/dsl'
|
||||
|
||||
type CreateReleaseConfig = {
|
||||
appInstanceId: string
|
||||
@ -1,4 +1,4 @@
|
||||
import type { SourceAppPickerValue } from './source-app-picker-value'
|
||||
import type { SourceAppPickerValue } from '../ui/source-app-picker-value'
|
||||
|
||||
export type ReleaseSourceMode = 'sourceApp' | 'dsl'
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateReleaseFormValues } from './types'
|
||||
import type { CreateReleaseFormValues } from '../state/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
closeCreateReleaseDialogAtom,
|
||||
useCreateReleaseFormApi,
|
||||
} from './atoms'
|
||||
} from '../state'
|
||||
import {
|
||||
createReleaseReadiness,
|
||||
useCreateReleaseSourceSelection,
|
||||
@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateReleaseFormValues } from './types'
|
||||
import type { CreateReleaseFormValues } from '../state/types'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UnsupportedDslNodesAlert } from '../unsupported-dsl-nodes-alert'
|
||||
import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert'
|
||||
import {
|
||||
createReleaseSubmitUnsupportedDslNodesAtom,
|
||||
useCreateReleaseFormApi,
|
||||
} from './atoms'
|
||||
} from '../state'
|
||||
import {
|
||||
useCreateReleaseSourceSelection,
|
||||
useReleaseContentCheck,
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateReleaseFormValues } from './types'
|
||||
import type { CreateReleaseFormValues } from '../state/types'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
@ -8,7 +8,6 @@ import { ScopeProvider } from 'jotai-scope'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { CreateReleaseActions } from './actions'
|
||||
import {
|
||||
closeCreateReleaseDialogAtom,
|
||||
createReleaseDialogOpenAtom,
|
||||
@ -16,12 +15,13 @@ import {
|
||||
openCreateReleaseDialogAtom,
|
||||
useCreateReleaseConfig,
|
||||
useCreateReleaseFormApi,
|
||||
} from './atoms'
|
||||
} from '../state'
|
||||
import { useCreateReleaseForm } from '../state/use-create-release-form'
|
||||
import { CreateReleaseActions } from './actions'
|
||||
import { ReleaseContentFeedback } from './content-feedback'
|
||||
import { ReleaseMetadataFields } from './metadata-fields'
|
||||
import { workflowSourceAppPickerValue } from './source-app-picker-value'
|
||||
import { ReleaseSourceSection } from './source-section'
|
||||
import { useCreateReleaseForm } from './use-create-release-form'
|
||||
import { useCreateReleaseSubmission } from './use-create-release-submission'
|
||||
|
||||
const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1
|
||||
@ -3,12 +3,13 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCreateReleaseFormApi } from './atoms'
|
||||
import { useCreateReleaseFormApi } from '../state'
|
||||
import {
|
||||
RELEASE_NAME_REQUIRED_ERROR,
|
||||
validateReleaseName,
|
||||
} from './use-create-release-form'
|
||||
} from '../state/use-create-release-form'
|
||||
|
||||
const DESCRIPTION_MAX_LENGTH = 512
|
||||
const DESCRIPTION_WARN_THRESHOLD = 460
|
||||
@ -20,6 +21,11 @@ function hasReleaseNameRequiredError(errors: unknown[]) {
|
||||
export function ReleaseMetadataFields() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const form = useCreateReleaseFormApi()
|
||||
const releaseNameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
releaseNameInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -37,6 +43,7 @@ export function ReleaseMetadataFields() {
|
||||
{t('versions.releaseNameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
ref={releaseNameInputRef}
|
||||
id="release-name"
|
||||
name="releaseName"
|
||||
placeholder={t('versions.releaseNamePlaceholder')}
|
||||
@ -47,7 +54,6 @@ export function ReleaseMetadataFields() {
|
||||
aria-describedby={hasReleaseNameRequiredError(field.state.meta.errors) ? 'release-name-error' : undefined}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={event => field.handleChange(event.target.value)}
|
||||
autoFocus
|
||||
className="h-9"
|
||||
/>
|
||||
{hasReleaseNameRequiredError(field.state.meta.errors) && (
|
||||
@ -1,5 +1,5 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { isWorkflowAppMode } from '../../app-mode'
|
||||
import { isWorkflowAppMode } from './source-app-mode'
|
||||
|
||||
export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>>
|
||||
|
||||
@ -21,8 +21,8 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { isWorkflowApp } from '../../app-mode'
|
||||
import { TitleTooltip } from '../title-tooltip'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { isWorkflowApp } from './source-app-mode'
|
||||
|
||||
const SOURCE_APP_PAGE_SIZE = 20
|
||||
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReleaseSourceMode } from './types'
|
||||
import type { ReleaseSourceMode } from '../state/types'
|
||||
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -11,7 +11,7 @@ import {
|
||||
resetCreateReleaseDslFileAtom,
|
||||
selectCreateReleaseDslFileAtom,
|
||||
useCreateReleaseFormApi,
|
||||
} from './atoms'
|
||||
} from '../state'
|
||||
import { SourceAppPicker } from './source-app-picker'
|
||||
|
||||
function selectedReleaseSourceMode(value: readonly ReleaseSourceMode[] | undefined) {
|
||||
@ -1,19 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateReleaseResponse } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { CreateReleaseFormValues } from './types'
|
||||
import type { CreateReleaseFormValues } from '../state/types'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentErrorMessage, unsupportedDslNodeError } from '../../error'
|
||||
import { deploymentErrorMessage, unsupportedDslNodeError } from '../../shared/domain/error'
|
||||
import {
|
||||
clearCreateReleaseSubmissionErrorAtom,
|
||||
closeCreateReleaseDialogAtom,
|
||||
createReleaseSubmitUnsupportedDslNodesAtom,
|
||||
useCreateReleaseConfig,
|
||||
} from './atoms'
|
||||
} from '../state'
|
||||
import {
|
||||
canCheckReleaseSourceContent,
|
||||
useCreateReleaseSourceSelection,
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateReleaseDslState } from './atoms'
|
||||
import type { CreateReleaseFormValues, ReleaseSourceMode } from './types'
|
||||
import type { CreateReleaseDslState } from '../state'
|
||||
import type { CreateReleaseFormValues, ReleaseSourceMode } from '../state/types'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -9,7 +9,7 @@ import {
|
||||
createReleaseDialogOpenAtom,
|
||||
createReleaseDslStateAtom,
|
||||
useCreateReleaseConfig,
|
||||
} from './atoms'
|
||||
} from '../state'
|
||||
|
||||
export type CreateReleaseSourceSelection = CreateReleaseDslState & {
|
||||
hasUnsupportedDslMode: boolean
|
||||
@ -17,8 +17,8 @@ import {
|
||||
deployDrawerEnvironmentIdAtom,
|
||||
deployDrawerOpenAtom,
|
||||
deployDrawerReleaseIdAtom,
|
||||
} from '../store'
|
||||
import { DeployForm } from './deploy-drawer/form'
|
||||
} from './state'
|
||||
import { DeployForm } from './ui/form'
|
||||
|
||||
export function DeployDrawer() {
|
||||
const { t } = useTranslation('deployments')
|
||||
442
web/features/deployments/deploy-drawer/state/index.ts
Normal file
442
web/features/deployments/deploy-drawer/state/index.ts
Normal file
@ -0,0 +1,442 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
Environment,
|
||||
EnvironmentDeployment,
|
||||
EnvVarInput,
|
||||
Release,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { Getter } from 'jotai'
|
||||
import type {
|
||||
EnvVarBindingSlot,
|
||||
EnvVarValues,
|
||||
EnvVarValueSelection,
|
||||
} from '../../components/env-var-bindings'
|
||||
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
|
||||
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { envVarBindingSlotFromContract } from '../../components/env-var-bindings-utils'
|
||||
import {
|
||||
hasMissingRequiredRuntimeCredentialBinding,
|
||||
runtimeCredentialSlotKey,
|
||||
selectedDeploymentRuntimeCredentials,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '../../components/runtime-credential-bindings-utils'
|
||||
import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
|
||||
import { releaseDeploymentAction } from '../../shared/domain/release-action'
|
||||
|
||||
type OpenDeployDrawerParams = {
|
||||
appInstanceId: string
|
||||
environmentId?: string
|
||||
releaseId?: string
|
||||
}
|
||||
|
||||
export const deployDrawerOpenAtom = atom(false)
|
||||
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
|
||||
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
|
||||
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
|
||||
|
||||
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
|
||||
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
|
||||
set(deployDrawerEnvironmentIdAtom, params.environmentId)
|
||||
set(deployDrawerReleaseIdAtom, params.releaseId)
|
||||
set(deployDrawerOpenAtom, true)
|
||||
})
|
||||
|
||||
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
|
||||
set(deployDrawerOpenAtom, false)
|
||||
set(deployDrawerAppInstanceIdAtom, undefined)
|
||||
set(deployDrawerEnvironmentIdAtom, undefined)
|
||||
set(deployDrawerReleaseIdAtom, undefined)
|
||||
})
|
||||
|
||||
export type DeployReadyFormConfig = {
|
||||
appInstanceId: string
|
||||
environments: Environment[]
|
||||
releases: Release[]
|
||||
runtimeRows: EnvironmentDeployment[]
|
||||
defaultReleaseId?: string
|
||||
lockedEnvId?: string
|
||||
presetReleaseId?: string
|
||||
releaseEmptyLabel?: string
|
||||
}
|
||||
|
||||
export const deployReadyFormConfigAtom = atom<DeployReadyFormConfig | undefined>(undefined)
|
||||
|
||||
const selectedEnvIdAtom = atom<string | undefined>(undefined)
|
||||
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
|
||||
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
|
||||
export const deployEnvVarValuesAtom = atom<EnvVarValues>({})
|
||||
const showValidationErrorsAtom = atom(false)
|
||||
export const deployReadyFormLocalAtoms = [
|
||||
selectedEnvIdAtom,
|
||||
selectedReleaseIdAtom,
|
||||
manualBindingsAtom,
|
||||
deployEnvVarValuesAtom,
|
||||
showValidationErrorsAtom,
|
||||
] as const
|
||||
|
||||
function formConfig(get: Getter) {
|
||||
const config = get(deployReadyFormConfigAtom)
|
||||
if (!config)
|
||||
throw new Error('Missing deploy ready form config.')
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
const resetDeployBindingsAtom = atom(null, (_get, set) => {
|
||||
set(manualBindingsAtom, {})
|
||||
set(deployEnvVarValuesAtom, {})
|
||||
set(showValidationErrorsAtom, false)
|
||||
})
|
||||
|
||||
export const selectDeployEnvironmentAtom = atom(null, (_get, set, environmentId: string) => {
|
||||
set(selectedEnvIdAtom, environmentId)
|
||||
set(resetDeployBindingsAtom)
|
||||
})
|
||||
|
||||
export const selectDeployReleaseAtom = atom(null, (_get, set, releaseId: string) => {
|
||||
set(selectedReleaseIdAtom, releaseId)
|
||||
set(resetDeployBindingsAtom)
|
||||
})
|
||||
|
||||
export const showDeployValidationErrorsAtom = atom(null, (_get, set) => {
|
||||
set(showValidationErrorsAtom, true)
|
||||
})
|
||||
|
||||
export const deployShowValidationErrorsAtom = atom((get) => {
|
||||
return get(showValidationErrorsAtom)
|
||||
})
|
||||
|
||||
const deployPresetReleaseAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
return config.presetReleaseId ? config.releases.find(r => r.id === config.presetReleaseId) : undefined
|
||||
})
|
||||
|
||||
export const deployDisplayedReleaseAtom = atom((get): Release | undefined => {
|
||||
return get(deployPresetReleaseAtom)
|
||||
})
|
||||
|
||||
export const deployIsExistingReleaseAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
return Boolean(config.presetReleaseId)
|
||||
})
|
||||
|
||||
export const deployReleaseRowsAtom = atom((get) => {
|
||||
return formConfig(get).releases
|
||||
})
|
||||
|
||||
export const deployReleaseEmptyLabelAtom = atom((get) => {
|
||||
return formConfig(get).releaseEmptyLabel
|
||||
})
|
||||
|
||||
export const deployEnvironmentRowsAtom = atom((get) => {
|
||||
return formConfig(get).environments
|
||||
})
|
||||
|
||||
export const deployLockedEnvironmentIdAtom = atom((get) => {
|
||||
return formConfig(get).lockedEnvId
|
||||
})
|
||||
|
||||
export const deploySelectedEnvironmentIdAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
return get(selectedEnvIdAtom) ?? config.lockedEnvId ?? config.environments[0]?.id
|
||||
})
|
||||
|
||||
const deploySelectedEnvironmentAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
|
||||
return selectedEnvironmentId
|
||||
? config.environments.find(env => env.id === selectedEnvironmentId)
|
||||
: undefined
|
||||
})
|
||||
|
||||
export const deploySelectedReleaseIdAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
const displayedRelease = get(deployDisplayedReleaseAtom)
|
||||
return get(selectedReleaseIdAtom) ?? displayedRelease?.id ?? config.defaultReleaseId
|
||||
})
|
||||
|
||||
const deploySelectedReleaseAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
const selectedReleaseId = get(deploySelectedReleaseIdAtom)
|
||||
return selectedReleaseId
|
||||
? config.releases.find(release => release.id === selectedReleaseId)
|
||||
: undefined
|
||||
})
|
||||
|
||||
const deployTargetReleaseAtom = atom((get) => {
|
||||
return get(deployDisplayedReleaseAtom) ?? get(deploySelectedReleaseAtom)
|
||||
})
|
||||
|
||||
export const deployLockedEnvironmentAtom = atom((get) => {
|
||||
const config = formConfig(get)
|
||||
return config.lockedEnvId
|
||||
? config.environments.find(environment => environment.id === config.lockedEnvId)
|
||||
: undefined
|
||||
})
|
||||
|
||||
export const deployTargetReleaseIdAtom = atom((get) => {
|
||||
const targetRelease = get(deployTargetReleaseAtom)
|
||||
return targetRelease?.id ?? get(deploySelectedReleaseIdAtom)
|
||||
})
|
||||
|
||||
export const deployHasSelectedEnvironmentAtom = atom((get) => {
|
||||
return Boolean(get(deploySelectedEnvironmentAtom))
|
||||
})
|
||||
|
||||
const releaseDeploymentOptionsQueryAtom = atomWithQuery((get) => {
|
||||
const hasSelectedEnvironment = get(deployHasSelectedEnvironmentAtom)
|
||||
const releaseId = get(deployTargetReleaseIdAtom)
|
||||
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
|
||||
const enabled = Boolean(releaseId && selectedEnvironmentId && hasSelectedEnvironment)
|
||||
|
||||
return {
|
||||
...consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
|
||||
input: {
|
||||
body: {
|
||||
releaseId: releaseId ?? '',
|
||||
environmentId: selectedEnvironmentId ?? '',
|
||||
},
|
||||
},
|
||||
enabled,
|
||||
}),
|
||||
retry: false,
|
||||
}
|
||||
})
|
||||
|
||||
export const deployBindingSlotsAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
|
||||
|
||||
return deploymentOptionsQuery.data?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
|
||||
})
|
||||
|
||||
export const deployEnvVarSlotsAtom = atom((get): EnvVarBindingSlot[] => {
|
||||
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
|
||||
|
||||
return deploymentOptionsQuery.data?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
|
||||
const bindingSlot = envVarBindingSlotFromContract(slot)
|
||||
return bindingSlot ? [bindingSlot] : []
|
||||
}) ?? []
|
||||
})
|
||||
|
||||
export const deployIsBindingOptionsLoadingAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
|
||||
const releaseId = get(deployTargetReleaseIdAtom)
|
||||
|
||||
return Boolean(
|
||||
releaseId
|
||||
&& get(deployHasSelectedEnvironmentAtom)
|
||||
&& (deploymentOptionsQuery.isLoading || deploymentOptionsQuery.isFetching),
|
||||
)
|
||||
})
|
||||
|
||||
export const deployHasBindingOptionsErrorAtom = atom((get) => {
|
||||
return get(releaseDeploymentOptionsQueryAtom).isError
|
||||
})
|
||||
|
||||
const deployIsBindingOptionsReadyAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
|
||||
const releaseId = get(deployTargetReleaseIdAtom)
|
||||
|
||||
return Boolean(
|
||||
releaseId
|
||||
&& get(deployHasSelectedEnvironmentAtom)
|
||||
&& deploymentOptionsQuery.data
|
||||
&& !get(deployIsBindingOptionsLoadingAtom)
|
||||
&& !get(deployHasBindingOptionsErrorAtom),
|
||||
)
|
||||
})
|
||||
|
||||
function deployEnvVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
|
||||
return selection?.valueSource
|
||||
?? (slot.hasLastValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
|
||||
: slot.hasDefaultValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
|
||||
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
|
||||
}
|
||||
|
||||
function deployEnvVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] {
|
||||
const valueSource = deployEnvVarValueSource(slot, selection)
|
||||
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) {
|
||||
return slot.hasLastValue
|
||||
? [{ key: slot.key, valueSource }]
|
||||
: []
|
||||
}
|
||||
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) {
|
||||
return slot.hasDefaultValue
|
||||
? [{ key: slot.key, valueSource }]
|
||||
: []
|
||||
}
|
||||
|
||||
if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value))))
|
||||
return []
|
||||
|
||||
return [{
|
||||
key: slot.key,
|
||||
value: selection.value,
|
||||
valueSource,
|
||||
}]
|
||||
}
|
||||
|
||||
function deployEnvVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
|
||||
const valueSource = deployEnvVarValueSource(slot, selection)
|
||||
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT)
|
||||
return Boolean(slot.hasLastValue)
|
||||
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT)
|
||||
return Boolean(slot.hasDefaultValue)
|
||||
if (!selection?.value)
|
||||
return false
|
||||
|
||||
return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value))
|
||||
}
|
||||
|
||||
export const deploySelectedBindingsAtom = atom((get) => {
|
||||
return selectedRuntimeCredentialSelections(get(deployBindingSlotsAtom), get(manualBindingsAtom))
|
||||
})
|
||||
|
||||
const deployDeploymentCredentialsAtom = atom((get) => {
|
||||
return selectedDeploymentRuntimeCredentials(get(deployBindingSlotsAtom), get(deploySelectedBindingsAtom))
|
||||
})
|
||||
|
||||
const deployDeploymentEnvVarsAtom = atom((get) => {
|
||||
const envVarValues = get(deployEnvVarValuesAtom)
|
||||
|
||||
return get(deployEnvVarSlotsAtom).flatMap(slot => deployEnvVarInput(slot, envVarValues[slot.key]))
|
||||
})
|
||||
|
||||
const deployRequiredBindingsReadyAtom = atom((get) => {
|
||||
const selectedBindings = get(deploySelectedBindingsAtom)
|
||||
|
||||
return get(deployBindingSlotsAtom).every(slot =>
|
||||
!hasMissingRequiredRuntimeCredentialBinding(slot, selectedBindings[runtimeCredentialSlotKey(slot)]),
|
||||
)
|
||||
})
|
||||
|
||||
const deployRequiredEnvVarsReadyAtom = atom((get) => {
|
||||
const envVarValues = get(deployEnvVarValuesAtom)
|
||||
|
||||
return get(deployEnvVarSlotsAtom).every(slot =>
|
||||
deployEnvVarSelectionReady(slot, envVarValues[slot.key]),
|
||||
)
|
||||
})
|
||||
|
||||
export const selectDeployBindingAtom = atom(null, (get, set, slot: string, value: string) => {
|
||||
set(manualBindingsAtom, {
|
||||
...get(manualBindingsAtom),
|
||||
[slot]: value,
|
||||
})
|
||||
})
|
||||
|
||||
export const setDeployEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => {
|
||||
set(deployEnvVarValuesAtom, {
|
||||
...get(deployEnvVarValuesAtom),
|
||||
[key]: value,
|
||||
})
|
||||
})
|
||||
|
||||
const promoteReleaseMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.deploymentService.promote.mutationOptions(),
|
||||
)
|
||||
|
||||
const rollbackReleaseMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.deploymentService.rollback.mutationOptions(),
|
||||
)
|
||||
|
||||
export const isDeployReleaseSubmittingAtom = atom((get) => {
|
||||
return get(promoteReleaseMutationAtom).isPending || get(rollbackReleaseMutationAtom).isPending
|
||||
})
|
||||
|
||||
export const canAttemptDeployAtom = atom((get) => {
|
||||
return Boolean(
|
||||
get(deploySelectedEnvironmentIdAtom)
|
||||
&& get(deploySelectedEnvironmentAtom)
|
||||
&& get(deployTargetReleaseIdAtom)
|
||||
&& get(deployIsBindingOptionsReadyAtom)
|
||||
&& !get(isDeployReleaseSubmittingAtom),
|
||||
)
|
||||
})
|
||||
|
||||
export const canSubmitDeployAtom = atom((get) => {
|
||||
return Boolean(
|
||||
get(canAttemptDeployAtom)
|
||||
&& get(deployRequiredBindingsReadyAtom)
|
||||
&& get(deployRequiredEnvVarsReadyAtom),
|
||||
)
|
||||
})
|
||||
|
||||
export const deployReleaseSubmissionAtom = atom(null, (get, set, {
|
||||
deployFailedMessage,
|
||||
}: {
|
||||
deployFailedMessage: string
|
||||
}) => {
|
||||
const config = formConfig(get)
|
||||
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
|
||||
const targetRelease = get(deployTargetReleaseAtom)
|
||||
const targetReleaseId = get(deployTargetReleaseIdAtom)
|
||||
|
||||
if (!targetReleaseId || !selectedEnvironmentId)
|
||||
return
|
||||
|
||||
const idempotencyKey = createDeploymentIdempotencyKey()
|
||||
const currentRelease = config.runtimeRows.find(row => row.environment.id === selectedEnvironmentId)?.currentRelease
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease,
|
||||
currentRelease,
|
||||
releaseRows: config.releases,
|
||||
isExistingRelease: true,
|
||||
})
|
||||
const mutationOptions = {
|
||||
onSuccess: () => {
|
||||
set(closeDeployDrawerAtom)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(deployFailedMessage)
|
||||
},
|
||||
}
|
||||
|
||||
if (action === 'rollback') {
|
||||
get(rollbackReleaseMutationAtom).mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
targetReleaseId,
|
||||
idempotencyKey,
|
||||
},
|
||||
},
|
||||
mutationOptions,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const deploymentEnvVars = get(deployDeploymentEnvVarsAtom)
|
||||
get(promoteReleaseMutationAtom).mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId: config.appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
releaseId: targetReleaseId,
|
||||
credentials: get(deployDeploymentCredentialsAtom),
|
||||
envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined,
|
||||
idempotencyKey,
|
||||
},
|
||||
},
|
||||
mutationOptions,
|
||||
)
|
||||
})
|
||||
@ -1,22 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import type { CredentialSlot, Environment } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
|
||||
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
|
||||
import { DrawerDescription, DrawerTitle } from '@langgenius/dify-ui/drawer'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { formatDate, releaseCommit } from '../../release'
|
||||
import { DeploymentStateMessage } from '../empty-state'
|
||||
import { RuntimeCredentialBindingsPanel } from '../runtime-credential-bindings'
|
||||
import { DeploymentStateMessage } from '../../components/empty-state'
|
||||
import { RuntimeCredentialBindingsPanel } from '../../components/runtime-credential-bindings'
|
||||
import { formatDate, releaseCommit } from '../../shared/domain/release'
|
||||
import {
|
||||
deployDisplayedReleaseAtom,
|
||||
deployEnvironmentRowsAtom,
|
||||
deployIsExistingReleaseAtom,
|
||||
deployLockedEnvironmentAtom,
|
||||
deployLockedEnvironmentIdAtom,
|
||||
deployReleaseEmptyLabelAtom,
|
||||
deployReleaseRowsAtom,
|
||||
deploySelectedEnvironmentIdAtom,
|
||||
deploySelectedReleaseIdAtom,
|
||||
selectDeployEnvironmentAtom,
|
||||
selectDeployReleaseAtom,
|
||||
} from '../state'
|
||||
import {
|
||||
DeploymentSelect,
|
||||
EnvironmentRow,
|
||||
Field,
|
||||
} from './select'
|
||||
import {
|
||||
useDeployEnvironmentField,
|
||||
useDeployReleaseField,
|
||||
} from './use-deploy-ready-form'
|
||||
|
||||
export const DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME = 'max-h-none overflow-visible'
|
||||
|
||||
@ -102,14 +112,12 @@ export function DeployFormHeader() {
|
||||
|
||||
export function ReleaseField() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const {
|
||||
displayedRelease,
|
||||
emptyLabel,
|
||||
isExistingRelease,
|
||||
releases,
|
||||
selectedReleaseId,
|
||||
selectRelease,
|
||||
} = useDeployReleaseField()
|
||||
const displayedRelease = useAtomValue(deployDisplayedReleaseAtom)
|
||||
const emptyLabel = useAtomValue(deployReleaseEmptyLabelAtom)
|
||||
const isExistingRelease = useAtomValue(deployIsExistingReleaseAtom)
|
||||
const releases = useAtomValue(deployReleaseRowsAtom)
|
||||
const selectedReleaseId = useAtomValue(deploySelectedReleaseIdAtom)
|
||||
const selectRelease = useSetAtom(selectDeployReleaseAtom)
|
||||
|
||||
return (
|
||||
<Field label={t('deployDrawer.releaseLabel')}>
|
||||
@ -152,13 +160,11 @@ export function ReleaseField() {
|
||||
|
||||
export function EnvironmentField() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const {
|
||||
environments,
|
||||
lockedEnv,
|
||||
lockedEnvId,
|
||||
selectedEnvironmentId,
|
||||
selectEnvironment,
|
||||
} = useDeployEnvironmentField()
|
||||
const environments = useAtomValue(deployEnvironmentRowsAtom)
|
||||
const lockedEnv = useAtomValue(deployLockedEnvironmentAtom)
|
||||
const lockedEnvId = useAtomValue(deployLockedEnvironmentIdAtom)
|
||||
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
|
||||
const selectEnvironment = useSetAtom(selectDeployEnvironmentAtom)
|
||||
|
||||
return (
|
||||
<Field
|
||||
@ -11,9 +11,13 @@ import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { isAvailableDeploymentTarget } from '../../runtime-status'
|
||||
import { closeDeployDrawerAtom } from '../../store'
|
||||
import { EnvVarBindingsPanel } from '../env-var-bindings'
|
||||
import { EnvVarBindingsPanel } from '../../components/env-var-bindings'
|
||||
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
|
||||
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
|
||||
import {
|
||||
currentReleaseIdForEnvironment,
|
||||
selectableDeployReleases,
|
||||
} from '../state/release-options'
|
||||
import {
|
||||
BindingOptionsPanel,
|
||||
DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME,
|
||||
@ -22,22 +26,6 @@ import {
|
||||
ReleaseField,
|
||||
} from './form-sections'
|
||||
import { DeployFormSkeleton } from './form-skeleton'
|
||||
import {
|
||||
currentReleaseIdForEnvironment,
|
||||
selectableDeployReleases,
|
||||
} from './release-options'
|
||||
import {
|
||||
deployHasSelectedEnvironmentAtom,
|
||||
deployReadyFormConfigAtom,
|
||||
deployReadyFormLocalAtoms,
|
||||
deploySelectedEnvironmentAtom,
|
||||
deploySelectedEnvironmentIdAtom,
|
||||
deployTargetReleaseIdAtom,
|
||||
showDeployValidationErrorsAtom,
|
||||
useDeployBindings,
|
||||
useDeployReleaseSubmission,
|
||||
useReleaseDeploymentOptions,
|
||||
} from './use-deploy-ready-form'
|
||||
|
||||
type DeployFormProps = {
|
||||
appInstanceId: string
|
||||
@ -55,40 +43,42 @@ type DeployReadyFormProps = DeployFormProps & {
|
||||
|
||||
function DeployRuntimeCredentialBindingsSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const deploymentOptions = useReleaseDeploymentOptions()
|
||||
const deploymentBindings = useDeployBindings({
|
||||
bindingSlots: deploymentOptions.bindingSlots,
|
||||
envVarSlots: deploymentOptions.envVarSlots,
|
||||
})
|
||||
const bindingSlots = useAtomValue(deployBindingSlotsAtom)
|
||||
const selectedBindings = useAtomValue(deploySelectedBindingsAtom)
|
||||
const isBindingOptionsLoading = useAtomValue(deployIsBindingOptionsLoadingAtom)
|
||||
const hasBindingOptionsError = useAtomValue(deployHasBindingOptionsErrorAtom)
|
||||
const showValidationErrors = useAtomValue(deployShowValidationErrorsAtom)
|
||||
const selectBinding = useSetAtom(selectDeployBindingAtom)
|
||||
|
||||
return (
|
||||
<BindingOptionsPanel
|
||||
slots={deploymentOptions.bindingSlots}
|
||||
selections={deploymentBindings.selectedBindings}
|
||||
isLoading={deploymentOptions.isBindingOptionsLoading}
|
||||
hasError={deploymentOptions.hasBindingOptionsError}
|
||||
bindingCountLabel={t('deployDrawer.bindingCount', { count: deploymentOptions.bindingSlots.length })}
|
||||
showMissingRequired={deploymentBindings.showValidationErrors}
|
||||
onChange={deploymentBindings.handleBindingChange}
|
||||
slots={bindingSlots}
|
||||
selections={selectedBindings}
|
||||
isLoading={isBindingOptionsLoading}
|
||||
hasError={hasBindingOptionsError}
|
||||
bindingCountLabel={t('deployDrawer.bindingCount', { count: bindingSlots.length })}
|
||||
showMissingRequired={showValidationErrors}
|
||||
onChange={selectBinding}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DeployEnvVarBindingsSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const deploymentOptions = useReleaseDeploymentOptions()
|
||||
const deploymentBindings = useDeployBindings({
|
||||
bindingSlots: deploymentOptions.bindingSlots,
|
||||
envVarSlots: deploymentOptions.envVarSlots,
|
||||
})
|
||||
const envVarSlots = useAtomValue(deployEnvVarSlotsAtom)
|
||||
const envVarValues = useAtomValue(deployEnvVarValuesAtom)
|
||||
const isBindingOptionsLoading = useAtomValue(deployIsBindingOptionsLoadingAtom)
|
||||
const hasBindingOptionsError = useAtomValue(deployHasBindingOptionsErrorAtom)
|
||||
const showValidationErrors = useAtomValue(deployShowValidationErrorsAtom)
|
||||
const setDeployEnvVar = useSetAtom(setDeployEnvVarAtom)
|
||||
|
||||
if (deploymentOptions.isBindingOptionsLoading || deploymentOptions.hasBindingOptionsError)
|
||||
if (isBindingOptionsLoading || hasBindingOptionsError)
|
||||
return null
|
||||
|
||||
return (
|
||||
<EnvVarBindingsPanel
|
||||
slots={deploymentOptions.envVarSlots}
|
||||
values={deploymentBindings.envVarValues}
|
||||
slots={envVarSlots}
|
||||
values={envVarValues}
|
||||
title={t('deployDrawer.envVars')}
|
||||
hint={t('deployDrawer.envVarHint')}
|
||||
envVarPlaceholder={t('deployDrawer.envVarPlaceholder')}
|
||||
@ -102,11 +92,11 @@ function DeployEnvVarBindingsSection() {
|
||||
}}
|
||||
sourceAriaLabel={key => t('deployDrawer.envVarSource.ariaLabel', { key })}
|
||||
defaultSourcePriority="lastDeployment"
|
||||
envVarCountLabel={t('deployDrawer.envVarCount', { count: deploymentOptions.envVarSlots.length })}
|
||||
envVarCountLabel={t('deployDrawer.envVarCount', { count: envVarSlots.length })}
|
||||
missingRequiredLabel={t('deployDrawer.missingRequiredEnvVar')}
|
||||
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
|
||||
showMissingRequired={deploymentBindings.showValidationErrors}
|
||||
onChange={deploymentBindings.handleEnvVarChange}
|
||||
showMissingRequired={showValidationErrors}
|
||||
onChange={setDeployEnvVar}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -141,32 +131,12 @@ function DeployFormBody() {
|
||||
function DeployFooter() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
|
||||
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
|
||||
const selectedEnvironment = useAtomValue(deploySelectedEnvironmentAtom)
|
||||
const targetReleaseId = useAtomValue(deployTargetReleaseIdAtom)
|
||||
const showValidationErrors = useSetAtom(showDeployValidationErrorsAtom)
|
||||
const deploymentOptions = useReleaseDeploymentOptions()
|
||||
const deploymentBindings = useDeployBindings({
|
||||
bindingSlots: deploymentOptions.bindingSlots,
|
||||
envVarSlots: deploymentOptions.envVarSlots,
|
||||
})
|
||||
const submission = useDeployReleaseSubmission({
|
||||
deploymentCredentials: deploymentBindings.deploymentCredentials,
|
||||
deploymentEnvVars: deploymentBindings.deploymentEnvVars,
|
||||
})
|
||||
const canAttemptDeploy = Boolean(
|
||||
selectedEnvironmentId
|
||||
&& selectedEnvironment
|
||||
&& targetReleaseId
|
||||
&& deploymentOptions.isBindingOptionsReady
|
||||
&& !submission.isSubmitting,
|
||||
)
|
||||
const canDeploy = Boolean(
|
||||
canAttemptDeploy
|
||||
&& deploymentBindings.requiredBindingsReady
|
||||
&& deploymentBindings.requiredEnvVarsReady,
|
||||
)
|
||||
const submitLabel = submission.isSubmitting ? t('deployDrawer.deploying') : t('deployDrawer.deploy')
|
||||
const submitDeployRelease = useSetAtom(deployReleaseSubmissionAtom)
|
||||
const canAttemptDeploy = useAtomValue(canAttemptDeployAtom)
|
||||
const canDeploy = useAtomValue(canSubmitDeployAtom)
|
||||
const isSubmitting = useAtomValue(isDeployReleaseSubmittingAtom)
|
||||
const submitLabel = isSubmitting ? t('deployDrawer.deploying') : t('deployDrawer.deploy')
|
||||
|
||||
function handleDeploy() {
|
||||
showValidationErrors()
|
||||
@ -174,7 +144,9 @@ function DeployFooter() {
|
||||
if (!canDeploy)
|
||||
return
|
||||
|
||||
submission.deployRelease()
|
||||
submitDeployRelease({
|
||||
deployFailedMessage: t('deployDrawer.deployFailed'),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -6,8 +6,8 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ModeBadge } from '../status-badge'
|
||||
import { TitleTooltip } from '../title-tooltip'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { ModeBadge } from './status-badge'
|
||||
|
||||
export function Field({ label, hint, children }: {
|
||||
label: string
|
||||
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../components/empty-state'
|
||||
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../runtime-status'
|
||||
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
|
||||
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
|
||||
import { NewDeploymentButton } from './deploy-tab/new-deployment-button'
|
||||
import {
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DeploymentStatusSummary } from '../deployment-status-summary'
|
||||
|
||||
describe('DeploymentStatusSummary', () => {
|
||||
// Runtime status should dominate empty release/deployment fields.
|
||||
describe('Status rendering', () => {
|
||||
it('should render undeploying instead of unknown when runtime is undeploying', () => {
|
||||
const row = {
|
||||
appInstanceId: 'app-instance',
|
||||
environment: {
|
||||
id: 'env-undeploying',
|
||||
displayName: 'Test CPU',
|
||||
description: '',
|
||||
mode: 'ENVIRONMENT_MODE_SHARED',
|
||||
backend: 'RUNTIME_BACKEND_K8S',
|
||||
namespace: '',
|
||||
apiServer: '',
|
||||
status: 'ENVIRONMENT_STATUS_READY',
|
||||
statusMessage: '',
|
||||
managedBy: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
runtimeEndpoint: '',
|
||||
cpuCount: 1,
|
||||
},
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
|
||||
updatedAt: '',
|
||||
} satisfies EnvironmentDeployment
|
||||
|
||||
render(<DeploymentStatusSummary row={row} />)
|
||||
|
||||
expect(screen.getByText('deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING')).toBeInTheDocument()
|
||||
expect(screen.queryByText('deployments.status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { releaseCommit } from '../../release'
|
||||
import { isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { releaseCommit } from '../../shared/domain/release'
|
||||
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
|
||||
import {
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
|
||||
@ -7,9 +7,9 @@ import { useSetAtom } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createDeploymentIdempotencyKey } from '../../idempotency'
|
||||
import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { openDeployDrawerAtom } from '../../deploy-drawer/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'
|
||||
@ -68,6 +68,7 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
className="flex shrink-0 items-center"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DeploymentStatusBadge } from '../../deployment-ui'
|
||||
import {
|
||||
isUndeployedDeploymentRow,
|
||||
} from '../../runtime-status'
|
||||
} from '../../shared/domain/runtime-status'
|
||||
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
|
||||
|
||||
export function DeploymentStatusSummary({ row }: {
|
||||
row: EnvironmentDeployment
|
||||
|
||||
@ -5,8 +5,8 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
|
||||
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
|
||||
|
||||
export function NewDeploymentButton({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
|
||||
@ -6,7 +6,7 @@ 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 '../components/create-release/control'
|
||||
import { CreateReleaseControl } from '../create-release'
|
||||
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
|
||||
import { DeploymentSidebar } from './deployment-sidebar'
|
||||
import { DeveloperApiHeaderActions, DeveloperApiHeaderSwitch } from './settings-tab/access/developer-api-section'
|
||||
|
||||
@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentStateMessage } from '../components/empty-state'
|
||||
import { hasRuntimeInstanceDeployment } from '../runtime-status'
|
||||
import { hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
|
||||
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './overview-tab/access-status-section'
|
||||
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
|
||||
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
|
||||
|
||||
@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import Link from '@/next/link'
|
||||
import { DeploymentStatusBadge } from '../../deployment-ui'
|
||||
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 = {
|
||||
|
||||
@ -8,8 +8,8 @@ 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 { hasRuntimeInstanceDeployment } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
|
||||
import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
|
||||
import { OVERVIEW_CARD_CLASS_NAME } from './card-styles'
|
||||
import { EnvironmentTile } from './environment-tile'
|
||||
|
||||
|
||||
@ -11,12 +11,12 @@ import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { DeploymentStatusBadge } from '../../deployment-ui'
|
||||
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
|
||||
import { releaseCommit } from '../../shared/domain/release'
|
||||
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
|
||||
import {
|
||||
deploymentStatusLabelKey,
|
||||
} from '../../deployment-ui-utils'
|
||||
import { releaseCommit } from '../../release'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
} from '../../shared/ui/deployment-status-style'
|
||||
import { OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
|
||||
import {
|
||||
renderActionLabel,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import { isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
|
||||
|
||||
export type Drift
|
||||
= | { kind: 'undeployed' }
|
||||
|
||||
@ -10,10 +10,10 @@ 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 { CreateReleaseControl } from '../../components/create-release/control'
|
||||
import { DeploymentEmptyState } from '../../components/empty-state'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { formatDate, releaseCommit } from '../../release'
|
||||
import { CreateReleaseControl } from '../../create-release'
|
||||
import { formatDate, releaseCommit } from '../../shared/domain/release'
|
||||
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
|
||||
|
||||
type ReleaseHeroProps = {
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useId, useState } from 'react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { generateApiTokenName } from './api-token-name'
|
||||
@ -46,6 +46,7 @@ export function ApiKeyGenerateMenu({
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const nameInputId = useId()
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
|
||||
const [draftName, setDraftName] = useState('')
|
||||
@ -58,6 +59,11 @@ export function ApiKeyGenerateMenu({
|
||||
const disabled = selectableEnvironments.length === 0
|
||||
const isCreating = generateApiKey.isPending
|
||||
|
||||
useEffect(() => {
|
||||
if (createDialogOpen)
|
||||
nameInputRef.current?.focus()
|
||||
}, [createDialogOpen])
|
||||
|
||||
function resetCreateDialog() {
|
||||
setCreateDialogOpen(false)
|
||||
setSelectedEnvironmentId(undefined)
|
||||
@ -161,10 +167,10 @@ export function ApiKeyGenerateMenu({
|
||||
{t('access.api.nameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
id={nameInputId}
|
||||
value={draftName}
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
aria-invalid={nameError || undefined}
|
||||
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
|
||||
placeholder={t('access.api.namePlaceholder')}
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
import type { ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
|
||||
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getReleaseSummaryDeployments } from '../release-deployments'
|
||||
|
||||
function environment(id: string, name: string): ReleaseSummary['deployedEnvironments'][number]['environment'] {
|
||||
return {
|
||||
id,
|
||||
displayName: name,
|
||||
description: '',
|
||||
mode: 'ENVIRONMENT_MODE_SHARED',
|
||||
backend: 'RUNTIME_BACKEND_K8S',
|
||||
namespace: '',
|
||||
apiServer: '',
|
||||
status: 'ENVIRONMENT_STATUS_READY',
|
||||
statusMessage: '',
|
||||
managedBy: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
runtimeEndpoint: '',
|
||||
cpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-deployments', () => {
|
||||
// Runtime statuses come from the generated enterprise API contract.
|
||||
describe('release summary deployments', () => {
|
||||
it('should keep runtime status values on release deployment rows', () => {
|
||||
const summary = {
|
||||
release: {
|
||||
id: 'release',
|
||||
appInstanceId: 'app-instance',
|
||||
displayName: 'Release',
|
||||
description: '',
|
||||
source: 'RELEASE_SOURCE_UPLOAD',
|
||||
sourceAppId: '',
|
||||
gateCommitId: '',
|
||||
requiredSlots: [],
|
||||
createdBy: {
|
||||
id: 'account',
|
||||
displayName: 'Account',
|
||||
},
|
||||
createdAt: '',
|
||||
},
|
||||
deployedEnvironments: [
|
||||
{
|
||||
environment: environment('env-ready', 'Ready'),
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
|
||||
},
|
||||
{
|
||||
environment: environment('env-deploying', 'Deploying'),
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING,
|
||||
},
|
||||
{
|
||||
environment: environment('env-failed', 'Failed'),
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED,
|
||||
},
|
||||
{
|
||||
environment: environment('env-invalid', 'Invalid'),
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_INVALID,
|
||||
},
|
||||
{
|
||||
environment: environment('env-undeploying', 'Undeploying'),
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
|
||||
},
|
||||
],
|
||||
environmentActions: [],
|
||||
activeEnvironmentCount: 5,
|
||||
} satisfies ReleaseSummary
|
||||
|
||||
expect(getReleaseSummaryDeployments(summary)).toEqual([
|
||||
{
|
||||
environmentId: 'env-ready',
|
||||
environmentName: 'Ready',
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
|
||||
},
|
||||
{
|
||||
environmentId: 'env-deploying',
|
||||
environmentName: 'Deploying',
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING,
|
||||
},
|
||||
{
|
||||
environmentId: 'env-failed',
|
||||
environmentName: 'Failed',
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED,
|
||||
},
|
||||
{
|
||||
environmentId: 'env-invalid',
|
||||
environmentName: 'Invalid',
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_INVALID,
|
||||
},
|
||||
{
|
||||
environmentId: 'env-undeploying',
|
||||
environmentName: 'Undeploying',
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,8 +5,8 @@ import type {
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { releaseDeploymentAction } from '../../release-action'
|
||||
import { isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { releaseDeploymentAction } from '../../shared/domain/release-action'
|
||||
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
|
||||
|
||||
export type DeployMenuRowState = 'deploy' | 'rollback' | 'current' | 'deploying'
|
||||
|
||||
|
||||
@ -15,8 +15,8 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
|
||||
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
|
||||
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
|
||||
import { DeleteReleaseDialog } from './delete-release-dialog'
|
||||
import {
|
||||
|
||||
@ -4,10 +4,10 @@ import type { ReleaseDeployment } from './release-deployments'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isRuntimeDeploymentInProgress } from '../../shared/domain/runtime-status'
|
||||
import {
|
||||
deploymentStatusToneClassNames,
|
||||
} from '../../deployment-ui-utils'
|
||||
import { isRuntimeDeploymentInProgress } from '../../runtime-status'
|
||||
} from '../../shared/ui/deployment-status-style'
|
||||
|
||||
export function DeployedToBadge({ item }: {
|
||||
item: ReleaseDeployment
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { fetchReleaseDsl } from '../../release-dsl'
|
||||
import { fetchReleaseDsl } from './release-dsl'
|
||||
|
||||
const YAML_EXTENSION_PATTERN = /\.ya?ml$/i
|
||||
const INVALID_FILENAME_CHARS_PATTERN = /[\\/:*?"<>|]+/g
|
||||
|
||||
@ -13,7 +13,7 @@ import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import {
|
||||
formatDate,
|
||||
releaseCommit,
|
||||
} from '../../release'
|
||||
} from '../../shared/domain/release'
|
||||
import {
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
|
||||
@ -6,7 +6,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
|
||||
import { RELEASE_HISTORY_PAGE_SIZE } from '../../data'
|
||||
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'
|
||||
|
||||
@ -1,242 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { InfiniteData } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../components/empty-state'
|
||||
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
|
||||
import { deploymentStatusPollingInterval } from '../runtime-status'
|
||||
import { CreateDeploymentButton } from './create-deployment-button'
|
||||
import { EnvironmentFilter } from './environment-filter'
|
||||
import { InstanceCard } from './instance-card'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
deploymentsListEnvironmentIdAtom,
|
||||
deploymentsListKeywordsAtom,
|
||||
envFilterQueryState,
|
||||
keywordsQueryState,
|
||||
} from './query-state'
|
||||
} from './state'
|
||||
import { DeploymentsListShell } from './ui/shell'
|
||||
|
||||
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
|
||||
|
||||
function listDeploymentStatusPollingInterval(data?: InfiniteData<ListAppInstanceSummariesResponse>) {
|
||||
const rows = data?.pages?.flatMap(page =>
|
||||
page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments),
|
||||
) ?? []
|
||||
|
||||
return deploymentStatusPollingInterval(rows)
|
||||
}
|
||||
|
||||
function DeploymentsListState({ children }: {
|
||||
function DeploymentsListStateBoundary({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return <DeploymentStateMessage variant="page">{children}</DeploymentStateMessage>
|
||||
}
|
||||
|
||||
function DeploymentsListEmpty() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
|
||||
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
|
||||
const hasFilter = Boolean(keywords.trim()) || Boolean(envFilter)
|
||||
|
||||
function clearFilters() {
|
||||
void setKeywords(null)
|
||||
void setEnvFilter(null)
|
||||
}
|
||||
const [envFilter] = useQueryState('env', envFilterQueryState)
|
||||
const [keywords] = useQueryState('keywords', keywordsQueryState)
|
||||
const stateKey = `${envFilter ?? 'all'}:${keywords}`
|
||||
|
||||
return (
|
||||
<DeploymentEmptyState
|
||||
variant="page"
|
||||
icon={hasFilter ? 'i-ri-search-line' : 'i-ri-rocket-line'}
|
||||
title={hasFilter ? t('list.emptyFilteredTitle') : t('list.emptyTitle')}
|
||||
description={hasFilter ? t('list.emptyFilteredDescription') : t('list.emptyDescription')}
|
||||
action={hasFilter
|
||||
? (
|
||||
<Button variant="secondary" size="small" onClick={clearFilters}>
|
||||
{t('list.clearFilters')}
|
||||
</Button>
|
||||
)
|
||||
: <CreateDeploymentButton />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InstanceCardSkeleton() {
|
||||
return (
|
||||
<div className="col-span-1 inline-flex min-h-40 min-w-0 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-w-0 px-4 pt-4">
|
||||
<SkeletonRectangle className="my-0 h-4 w-2/5 animate-pulse" />
|
||||
<SkeletonRectangle className="mt-2 h-3 w-3/5 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-8 px-4 pt-4">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
|
||||
<SkeletonRectangle className="my-0 h-5 w-22 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex h-11 min-w-0 items-center border-t border-divider-subtle px-4">
|
||||
<div className="flex min-w-0 grow items-center gap-2">
|
||||
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-18 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentsListSkeleton() {
|
||||
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
|
||||
<InstanceCardSkeleton key={key} />
|
||||
))
|
||||
}
|
||||
|
||||
function DeploymentsSearchInput({ className }: {
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
|
||||
|
||||
function handleKeywordsChange(next: string) {
|
||||
void setKeywords(next.trim() ? next : null, {
|
||||
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-50', className)}>
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" />
|
||||
<Input
|
||||
className="h-8 pr-8 pl-8"
|
||||
placeholder={t('filter.searchPlaceholder')}
|
||||
value={keywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
/>
|
||||
{keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('list.clearSearch')}
|
||||
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
|
||||
onClick={() => handleKeywordsChange('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentsListControls() {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-3 bg-background-body px-4 pt-5 pb-4 sm:px-6 lg:px-12 lg:pt-7 lg:pb-5">
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 sm:justify-start">
|
||||
<EnvironmentFilter className="min-w-0" />
|
||||
<CreateDeploymentButton className="shrink-0 sm:hidden" />
|
||||
</div>
|
||||
<DeploymentsSearchInput className="w-full sm:w-50 sm:shrink-0" />
|
||||
<CreateDeploymentButton className="hidden shrink-0 sm:ml-auto sm:inline-flex" />
|
||||
</div>
|
||||
</div>
|
||||
<ScopeProvider
|
||||
key={stateKey}
|
||||
atoms={[
|
||||
[deploymentsListEnvironmentIdAtom, envFilter],
|
||||
[deploymentsListKeywordsAtom, keywords],
|
||||
]}
|
||||
name="DeploymentsList"
|
||||
>
|
||||
{children}
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentsList() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [envFilter] = useQueryState('env', envFilterQueryState)
|
||||
const [keywords] = useQueryState('keywords', keywordsQueryState)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const queryKeywords = keywords.trim()
|
||||
const queryEnvironmentId = envFilter ?? undefined
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = useInfiniteQuery({
|
||||
...consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
pageNumber: Number(pageParam),
|
||||
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
|
||||
...(queryEnvironmentId ? { environmentId: queryEnvironmentId } : {}),
|
||||
...(queryKeywords ? { displayName: queryKeywords } : {}),
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
refetchInterval: query => listDeploymentStatusPollingInterval(query.state.data),
|
||||
})
|
||||
const pages = data?.pages ?? []
|
||||
const appInstanceSummaries = pages.flatMap(page => page.appInstanceSummaries)
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const showEmptyState = !showSkeleton && !isError && appInstanceSummaries.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isLoading || isFetchingNextPage || error)
|
||||
return
|
||||
|
||||
const anchor = anchorRef.current
|
||||
const container = containerRef.current
|
||||
if (!anchor || !container)
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting)
|
||||
void fetchNextPage()
|
||||
}, {
|
||||
root: container,
|
||||
rootMargin: '160px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
observer.observe(anchor)
|
||||
return () => observer.disconnect()
|
||||
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<DeploymentsListControls />
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-4 pt-2 sm:px-6 lg:px-12',
|
||||
showEmptyState && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{showSkeleton
|
||||
? <DeploymentsListSkeleton />
|
||||
: isError
|
||||
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
|
||||
: appInstanceSummaries.length === 0
|
||||
? <DeploymentsListEmpty />
|
||||
: appInstanceSummaries.map(summary => (
|
||||
<InstanceCard
|
||||
key={summary.appInstance.id}
|
||||
summary={summary}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && <DeploymentsListSkeleton />}
|
||||
</div>
|
||||
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
<div className="py-4" />
|
||||
</div>
|
||||
<DeploymentsListStateBoundary>
|
||||
<DeploymentsListShell />
|
||||
</DeploymentsListStateBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
import { parseAsString } from 'nuqs'
|
||||
|
||||
export const envFilterQueryState = parseAsString.withOptions({ history: 'push' })
|
||||
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })
|
||||
87
web/features/deployments/list/state/index.ts
Normal file
87
web/features/deployments/list/state/index.ts
Normal file
@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { InfiniteData, QueryKey } from '@tanstack/react-query'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { parseAsString } from 'nuqs'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../../shared/domain/pagination'
|
||||
import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status'
|
||||
|
||||
export const envFilterQueryState = parseAsString.withOptions({ history: 'push' })
|
||||
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })
|
||||
|
||||
export const deploymentsListKeywordsAtom = atom('')
|
||||
export const deploymentsListEnvironmentIdAtom = atom<string | null>(null)
|
||||
|
||||
function listDeploymentStatusPollingInterval(data?: InfiniteData<ListAppInstanceSummariesResponse>) {
|
||||
const rows = data?.pages?.flatMap(page =>
|
||||
page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments),
|
||||
) ?? []
|
||||
|
||||
return deploymentStatusPollingInterval(rows)
|
||||
}
|
||||
|
||||
export const deploymentsListQueryAtom = atomWithInfiniteQuery<
|
||||
ListAppInstanceSummariesResponse,
|
||||
Error,
|
||||
InfiniteData<ListAppInstanceSummariesResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>((get) => {
|
||||
const queryKeywords = get(deploymentsListKeywordsAtom).trim()
|
||||
const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined
|
||||
|
||||
return {
|
||||
...consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
pageNumber: Number(pageParam),
|
||||
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
|
||||
...(queryEnvironmentId ? { environmentId: queryEnvironmentId } : {}),
|
||||
...(queryKeywords ? { displayName: queryKeywords } : {}),
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
refetchInterval: query => listDeploymentStatusPollingInterval(query.state.data),
|
||||
}
|
||||
})
|
||||
|
||||
export const deploymentsListRowsAtom = atom((get) => {
|
||||
return get(deploymentsListQueryAtom).data?.pages.flatMap(page => page.appInstanceSummaries) ?? []
|
||||
})
|
||||
|
||||
export const deploymentsListShowSkeletonAtom = atom((get) => {
|
||||
const deploymentsListQuery = get(deploymentsListQueryAtom)
|
||||
const pages = deploymentsListQuery.data?.pages ?? []
|
||||
|
||||
return deploymentsListQuery.isLoading || (deploymentsListQuery.isFetching && pages.length === 0)
|
||||
})
|
||||
|
||||
export const deploymentsListShowEmptyStateAtom = atom((get) => {
|
||||
return !get(deploymentsListShowSkeletonAtom)
|
||||
&& !get(deploymentsListQueryAtom).isError
|
||||
&& get(deploymentsListRowsAtom).length === 0
|
||||
})
|
||||
|
||||
export const deploymentsListHasFilterAtom = atom((get) => {
|
||||
return Boolean(get(deploymentsListKeywordsAtom).trim() || get(deploymentsListEnvironmentIdAtom))
|
||||
})
|
||||
|
||||
export const environmentsFilterQueryAtom = atomWithQuery(() =>
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ -8,12 +8,14 @@ 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 './query-state'
|
||||
import {
|
||||
envFilterQueryState,
|
||||
environmentsFilterQueryAtom,
|
||||
} from '../state'
|
||||
|
||||
type EnvironmentFilterOption = {
|
||||
value: string | null
|
||||
@ -31,16 +33,7 @@ export function EnvironmentFilter({ className }: {
|
||||
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 environmentsQuery = useAtomValue(environmentsFilterQueryAtom)
|
||||
const environmentOptions: EnvironmentFilterOption[] = environmentsQuery.data?.environments
|
||||
?.map(environment => ({
|
||||
value: environment.id,
|
||||
@ -14,9 +14,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import Link from '@/next/link'
|
||||
import { EnvironmentDeploymentBadge } from '../deployment-ui'
|
||||
import { deploymentStatusLabelKey } from '../deployment-ui-utils'
|
||||
import { formatDate } from '../release'
|
||||
import { formatDate } from '../../shared/domain/release'
|
||||
import { EnvironmentDeploymentBadge } from '../../shared/ui/deployment-status-badge'
|
||||
import { deploymentStatusLabelKey } from '../../shared/ui/deployment-status-style'
|
||||
import { getInstanceTabHref } from './instance-card-utils'
|
||||
|
||||
const VISIBLE_ENVIRONMENT_COUNT = 3
|
||||
@ -2,8 +2,8 @@ import type {
|
||||
EnvironmentDeployment,
|
||||
Release,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { InstanceDetailTabKey } from '../detail/tabs'
|
||||
import { isUndeployedDeploymentRow } from '../runtime-status'
|
||||
import type { InstanceDetailTabKey } from '../../detail/tabs'
|
||||
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
|
||||
|
||||
export function getInstanceTabHref(appInstanceId: string, tabKey: InstanceDetailTabKey) {
|
||||
return `/deployments/${appInstanceId}/${tabKey}`
|
||||
@ -8,10 +8,10 @@ import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import Link from '@/next/link'
|
||||
import { CreateReleaseControl } from '../components/create-release/control'
|
||||
import { DeploymentActionsMenu } from '../components/deployment-actions'
|
||||
import { TitleTooltip } from '../components/title-tooltip'
|
||||
import { openDeployDrawerAtom } from '../store'
|
||||
import { DeploymentActionsMenu } from '../../components/deployment-actions'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { CreateReleaseControl } from '../../create-release'
|
||||
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
|
||||
import {
|
||||
DeploymentAccessLinks,
|
||||
DeploymentStatusContent,
|
||||
214
web/features/deployments/list/ui/shell.tsx
Normal file
214
web/features/deployments/list/ui/shell.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
|
||||
import {
|
||||
deploymentsListHasFilterAtom,
|
||||
deploymentsListQueryAtom,
|
||||
deploymentsListRowsAtom,
|
||||
deploymentsListShowEmptyStateAtom,
|
||||
deploymentsListShowSkeletonAtom,
|
||||
envFilterQueryState,
|
||||
keywordsQueryState,
|
||||
} from '../state'
|
||||
import { CreateDeploymentButton } from './create-deployment-button'
|
||||
import { EnvironmentFilter } from './environment-filter'
|
||||
import { InstanceCard } from './instance-card'
|
||||
|
||||
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
|
||||
|
||||
function DeploymentsListState({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return <DeploymentStateMessage variant="page">{children}</DeploymentStateMessage>
|
||||
}
|
||||
|
||||
function DeploymentsListEmpty() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const hasAtomFilter = useAtomValue(deploymentsListHasFilterAtom)
|
||||
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
|
||||
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
|
||||
const hasFilter = hasAtomFilter || Boolean(keywords.trim()) || Boolean(envFilter)
|
||||
|
||||
function clearFilters() {
|
||||
void setKeywords(null)
|
||||
void setEnvFilter(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<DeploymentEmptyState
|
||||
variant="page"
|
||||
icon={hasFilter ? 'i-ri-search-line' : 'i-ri-rocket-line'}
|
||||
title={hasFilter ? t('list.emptyFilteredTitle') : t('list.emptyTitle')}
|
||||
description={hasFilter ? t('list.emptyFilteredDescription') : t('list.emptyDescription')}
|
||||
action={hasFilter
|
||||
? (
|
||||
<Button variant="secondary" size="small" onClick={clearFilters}>
|
||||
{t('list.clearFilters')}
|
||||
</Button>
|
||||
)
|
||||
: <CreateDeploymentButton />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InstanceCardSkeleton() {
|
||||
return (
|
||||
<div className="col-span-1 inline-flex min-h-40 min-w-0 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-w-0 px-4 pt-4">
|
||||
<SkeletonRectangle className="my-0 h-4 w-2/5 animate-pulse" />
|
||||
<SkeletonRectangle className="mt-2 h-3 w-3/5 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-8 px-4 pt-4">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
|
||||
<SkeletonRectangle className="my-0 h-5 w-22 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex h-11 min-w-0 items-center border-t border-divider-subtle px-4">
|
||||
<div className="flex min-w-0 grow items-center gap-2">
|
||||
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-18 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentsListSkeleton() {
|
||||
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
|
||||
<InstanceCardSkeleton key={key} />
|
||||
))
|
||||
}
|
||||
|
||||
function DeploymentsSearchInput({ className }: {
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
|
||||
|
||||
function handleKeywordsChange(next: string) {
|
||||
void setKeywords(next.trim() ? next : null, {
|
||||
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
|
||||
shallow: false,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-50', className)}>
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" />
|
||||
<Input
|
||||
className="h-8 pr-8 pl-8"
|
||||
placeholder={t('filter.searchPlaceholder')}
|
||||
value={keywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
/>
|
||||
{keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('list.clearSearch')}
|
||||
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
|
||||
onClick={() => handleKeywordsChange('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentsListControls() {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-3 bg-background-body px-4 pt-5 pb-4 sm:px-6 lg:px-12 lg:pt-7 lg:pb-5">
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 sm:justify-start">
|
||||
<EnvironmentFilter className="min-w-0" />
|
||||
<CreateDeploymentButton className="shrink-0 sm:hidden" />
|
||||
</div>
|
||||
<DeploymentsSearchInput className="w-full sm:w-50 sm:shrink-0" />
|
||||
<CreateDeploymentButton className="hidden shrink-0 sm:ml-auto sm:inline-flex" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentsListShell() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const deploymentsListQuery = useAtomValue(deploymentsListQueryAtom)
|
||||
const appInstanceSummaries = useAtomValue(deploymentsListRowsAtom)
|
||||
const showSkeleton = useAtomValue(deploymentsListShowSkeletonAtom)
|
||||
const showEmptyState = useAtomValue(deploymentsListShowEmptyStateAtom)
|
||||
const {
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = deploymentsListQuery
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isLoading || isFetchingNextPage || error)
|
||||
return
|
||||
|
||||
const anchor = anchorRef.current
|
||||
const container = containerRef.current
|
||||
if (!anchor || !container)
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting)
|
||||
void fetchNextPage()
|
||||
}, {
|
||||
root: container,
|
||||
rootMargin: '160px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
observer.observe(anchor)
|
||||
return () => observer.disconnect()
|
||||
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<DeploymentsListControls />
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-4 pt-2 sm:px-6 lg:px-12',
|
||||
showEmptyState && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{showSkeleton
|
||||
? <DeploymentsListSkeleton />
|
||||
: isError
|
||||
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
|
||||
: appInstanceSummaries.length === 0
|
||||
? <DeploymentsListEmpty />
|
||||
: appInstanceSummaries.map(summary => (
|
||||
<InstanceCard
|
||||
key={summary.appInstance.id}
|
||||
summary={summary}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && <DeploymentsListSkeleton />}
|
||||
</div>
|
||||
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
<div className="py-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Nav from '@/app/components/header/nav'
|
||||
import { useParams, useRouter, useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
|
||||
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../shared/domain/pagination'
|
||||
|
||||
function navItemFromListApp(app: AppInstance): NavItem {
|
||||
const id = app.id
|
||||
|
||||
@ -7,11 +7,11 @@ import type {
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isRuntimeDeploymentInProgress } from '../domain/runtime-status'
|
||||
import {
|
||||
deploymentStatusLabelKey,
|
||||
deploymentStatusToneClassNames,
|
||||
} from './deployment-ui-utils'
|
||||
import { isRuntimeDeploymentInProgress } from './runtime-status'
|
||||
} from './deployment-status-style'
|
||||
|
||||
type DeploymentStatusBadgeProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
|
||||
status: RuntimeInstanceStatusValue
|
||||
@ -1,25 +0,0 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
type OpenDeployDrawerParams = {
|
||||
appInstanceId: string
|
||||
environmentId?: string
|
||||
releaseId?: string
|
||||
}
|
||||
|
||||
export const deployDrawerOpenAtom = atom(false)
|
||||
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
|
||||
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
|
||||
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
|
||||
|
||||
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
|
||||
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
|
||||
set(deployDrawerEnvironmentIdAtom, params.environmentId)
|
||||
set(deployDrawerReleaseIdAtom, params.releaseId)
|
||||
set(deployDrawerOpenAtom, true)
|
||||
})
|
||||
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
|
||||
set(deployDrawerOpenAtom, false)
|
||||
set(deployDrawerAppInstanceIdAtom, undefined)
|
||||
set(deployDrawerEnvironmentIdAtom, undefined)
|
||||
set(deployDrawerReleaseIdAtom, undefined)
|
||||
})
|
||||
Reference in New Issue
Block a user