mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
refactor(web): organize deployment feature state (#38065)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
parent
8218694691
commit
a14310fc62
@ -25,6 +25,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- 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 choices.
|
||||
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
|
||||
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
|
||||
- Module README whitelist: `@/service/client`, `@/next/*`.
|
||||
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
|
||||
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
|
||||
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { AccessTab } from '@/features/deployments/detail/access-tab'
|
||||
import { DeploymentAccess } from '@/features/deployments/detail/access'
|
||||
|
||||
export default function InstanceDetailAccessPage() {
|
||||
return <AccessTab />
|
||||
return <DeploymentAccess />
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
|
||||
import { DeploymentApiTokens } from '@/features/deployments/detail/api-tokens'
|
||||
|
||||
export default function InstanceDetailApiTokensPage() {
|
||||
return <DeveloperApiTab />
|
||||
return <DeploymentApiTokens />
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
|
||||
import { DeploymentInstances } from '@/features/deployments/detail/instances'
|
||||
|
||||
export default function InstanceDetailInstancesPage() {
|
||||
return <DeployTab />
|
||||
return <DeploymentInstances />
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
|
||||
import { DeploymentOverview } from '@/features/deployments/detail/overview'
|
||||
|
||||
export default function InstanceDetailOverviewPage() {
|
||||
return <OverviewTab />
|
||||
return <DeploymentOverview />
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
|
||||
import { DeploymentReleases } from '@/features/deployments/detail/releases'
|
||||
|
||||
export default function InstanceDetailReleasesPage() {
|
||||
return <VersionsTab />
|
||||
return <DeploymentReleases />
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ function routeParamsKey(params: NextRouteParams) {
|
||||
}
|
||||
|
||||
export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params)
|
||||
export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname)
|
||||
|
||||
export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => {
|
||||
const nextParams = normalizeNextRouteParams(routeState.params)
|
||||
|
||||
101
web/features/deployments/README.md
Normal file
101
web/features/deployments/README.md
Normal file
@ -0,0 +1,101 @@
|
||||
# Deployments
|
||||
|
||||
Deployment app instance, release, runtime target, access, and developer API feature modules.
|
||||
|
||||
## Internal Module Dependency Graph
|
||||
|
||||
The graph shows product module dependencies inside `web/features/deployments`. It omits route-state plumbing and shared support modules.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
list["list"]
|
||||
detail["detail"]
|
||||
createGuide["create-guide"]
|
||||
createRelease["create-release"]
|
||||
deployDrawer["deploy-drawer"]
|
||||
deploymentActions["deployment-actions"]
|
||||
|
||||
detailOverview["detail/overview"]
|
||||
detailInstances["detail/instances"]
|
||||
detailAccess["detail/access"]
|
||||
detailApiTokens["detail/api-tokens"]
|
||||
detailReleases["detail/releases"]
|
||||
|
||||
accessChannels["detail/access/channels"]
|
||||
accessPermissions["detail/access/permissions"]
|
||||
accessSubjectSelector["detail/access/permissions/access-subject-selector"]
|
||||
apiTokenManagement["detail/api-tokens/api-token-management"]
|
||||
apiKeys["detail/api-tokens/api-keys"]
|
||||
apiDocs["detail/api-tokens/docs"]
|
||||
instanceEnvironmentList["detail/instances/environment-list"]
|
||||
instanceHeaderActions["detail/instances/header-actions"]
|
||||
instanceRowActions["detail/instances/row-actions"]
|
||||
overviewAccessSummary["detail/overview/access-summary"]
|
||||
overviewEnvironmentStatus["detail/overview/environment-status"]
|
||||
overviewReleaseSummary["detail/overview/release-summary"]
|
||||
releaseActions["detail/releases/release-actions"]
|
||||
releaseHistory["detail/releases/release-history"]
|
||||
|
||||
list --> createGuide
|
||||
list --> createRelease
|
||||
list --> deployDrawer
|
||||
list --> deploymentActions
|
||||
list --> detail
|
||||
|
||||
detail --> createRelease
|
||||
detail --> deploymentActions
|
||||
detail --> detailOverview
|
||||
detail --> detailInstances
|
||||
detail --> detailAccess
|
||||
detail --> detailApiTokens
|
||||
detail --> detailReleases
|
||||
detail --> instanceHeaderActions
|
||||
|
||||
detailOverview --> overviewAccessSummary
|
||||
detailOverview --> overviewEnvironmentStatus
|
||||
detailOverview --> overviewReleaseSummary
|
||||
|
||||
overviewEnvironmentStatus --> deployDrawer
|
||||
overviewReleaseSummary --> createRelease
|
||||
|
||||
detailInstances --> detail
|
||||
detailInstances --> instanceEnvironmentList
|
||||
detailInstances --> instanceHeaderActions
|
||||
instanceEnvironmentList --> detailInstances
|
||||
instanceEnvironmentList --> instanceRowActions
|
||||
instanceHeaderActions --> deployDrawer
|
||||
instanceHeaderActions --> detail
|
||||
instanceRowActions --> deployDrawer
|
||||
|
||||
detailAccess --> accessChannels
|
||||
detailAccess --> accessPermissions
|
||||
accessChannels --> detailAccess
|
||||
accessPermissions --> detailAccess
|
||||
accessPermissions --> accessSubjectSelector
|
||||
|
||||
detailApiTokens --> apiTokenManagement
|
||||
apiTokenManagement --> detailApiTokens
|
||||
apiTokenManagement --> apiKeys
|
||||
apiTokenManagement --> apiDocs
|
||||
apiKeys --> detailApiTokens
|
||||
|
||||
detailReleases --> releaseHistory
|
||||
releaseActions --> deployDrawer
|
||||
releaseHistory --> detailReleases
|
||||
releaseHistory --> releaseActions
|
||||
```
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| `list` | Owns the deployment app instance list surface. |
|
||||
| `detail` | Owns the deployment app instance detail shell and route tabs. |
|
||||
| `create-guide` | Owns the create deployment guide workflow. |
|
||||
| `create-release` | Owns release creation entry points and dialog state. |
|
||||
| `deploy-drawer` | Owns deployment target selection and submit workflow state. |
|
||||
| `deployment-actions` | Owns app instance edit and delete action surfaces. |
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { atomWithLazy } from 'jotai/utils'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export const deploymentActionAppInstanceIdAtom = atomWithLazy<string>(() => {
|
||||
throw new Error('Missing deployment action app instance id.')
|
||||
})
|
||||
|
||||
export const editDeploymentDialogOpenAtom = atom(false)
|
||||
export const deleteDeploymentDialogOpenAtom = atom(false)
|
||||
|
||||
export const deploymentActionAppInstanceQueryOptionsAtom = atom((get) => {
|
||||
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
|
||||
|
||||
return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const deploymentActionAppInstanceQueryAtom = atomWithQuery((get) => {
|
||||
return get(deploymentActionAppInstanceQueryOptionsAtom)
|
||||
})
|
||||
|
||||
export const openEditDeploymentDialogAtom = atom(null, (_get, set) => {
|
||||
set(deleteDeploymentDialogOpenAtom, false)
|
||||
set(editDeploymentDialogOpenAtom, true)
|
||||
})
|
||||
|
||||
export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => {
|
||||
set(editDeploymentDialogOpenAtom, false)
|
||||
set(deleteDeploymentDialogOpenAtom, true)
|
||||
})
|
||||
|
||||
export const deploymentActionsLocalAtoms = [
|
||||
editDeploymentDialogOpenAtom,
|
||||
deleteDeploymentDialogOpenAtom,
|
||||
] as const
|
||||
18
web/features/deployments/create-guide/README.md
Normal file
18
web/features/deployments/create-guide/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Deployment Create Guide
|
||||
|
||||
Guided deployment creation workflow for selecting a source, defining the initial release, and optionally deploying to an environment.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| -------- | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the import source step. |
|
||||
| `app/components/base/app-icon` | Renders app icons consistently in source app options. |
|
||||
| `app/components/base/skeleton` | Reuses the existing skeleton primitive for source and target loading states. |
|
||||
| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. |
|
||||
17
web/features/deployments/create-guide/link.tsx
Normal file
17
web/features/deployments/create-guide/link.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import Link from '@/next/link'
|
||||
|
||||
const createDeploymentGuideHref = '/deployments/create'
|
||||
|
||||
type CreateDeploymentGuideLinkProps = Omit<ComponentProps<typeof Link>, 'href'>
|
||||
|
||||
export function CreateDeploymentGuideLink(props: CreateDeploymentGuideLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
href={createDeploymentGuideHref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,27 @@
|
||||
import type { Getter } from 'jotai'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
dslFileAtom,
|
||||
effectiveMethodAtom,
|
||||
instanceNameAtom,
|
||||
methodAtom,
|
||||
releaseNameAtom,
|
||||
selectedAppAtom,
|
||||
stepAtom,
|
||||
} from '../primitives'
|
||||
import {
|
||||
dslDefaultAppNameAtom,
|
||||
dslReadErrorAtom,
|
||||
isReadingDslAtom,
|
||||
sourceAppsQueryAtom,
|
||||
} from '../source'
|
||||
import {
|
||||
continueFromSourceAtom,
|
||||
selectDslFileAtom,
|
||||
selectMethodAtom,
|
||||
sourceCanGoNextAtom,
|
||||
} from '../workflow'
|
||||
|
||||
type QueryOptions = {
|
||||
enabled?: boolean
|
||||
@ -119,10 +140,6 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
async function loadState() {
|
||||
return await import('../index')
|
||||
}
|
||||
|
||||
function workflowDsl() {
|
||||
return [
|
||||
'app:',
|
||||
@ -137,30 +154,27 @@ describe('create deployment guide state', () => {
|
||||
mockQueryResults.current.clear()
|
||||
})
|
||||
|
||||
it('should keep the guide on source app mode when DSL import is disabled', async () => {
|
||||
const state = await loadState()
|
||||
it('should keep the guide on source app mode when DSL import is disabled', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(state.selectMethodAtom, 'importDsl')
|
||||
store.set(selectMethodAtom, 'importDsl')
|
||||
|
||||
expect(store.get(state.methodAtom)).toBe('bindApp')
|
||||
expect(store.get(state.effectiveMethodAtom)).toBe('bindApp')
|
||||
expect(store.get(methodAtom)).toBe('bindApp')
|
||||
expect(store.get(effectiveMethodAtom)).toBe('bindApp')
|
||||
})
|
||||
|
||||
it('should keep source app loading enabled if stale state points to DSL import', async () => {
|
||||
const state = await loadState()
|
||||
it('should keep source app loading enabled if stale state points to DSL import', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(state.methodAtom, 'importDsl')
|
||||
store.set(methodAtom, 'importDsl')
|
||||
|
||||
const sourceAppsQuery = store.get(state.sourceAppsQueryAtom) as unknown as { enabled?: boolean }
|
||||
const sourceAppsQuery = store.get(sourceAppsQueryAtom) as unknown as { enabled?: boolean }
|
||||
|
||||
expect(store.get(state.effectiveMethodAtom)).toBe('bindApp')
|
||||
expect(store.get(effectiveMethodAtom)).toBe('bindApp')
|
||||
expect(sourceAppsQuery.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should continue from source app mode and auto-fill unique release metadata', async () => {
|
||||
const state = await loadState()
|
||||
it('should continue from source app mode and auto-fill unique release metadata', () => {
|
||||
const store = createStore()
|
||||
mockQueryResults.current.set('sourceApps', {
|
||||
data: {
|
||||
@ -208,39 +222,38 @@ describe('create deployment guide state', () => {
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
expect(store.get(state.sourceCanGoNextAtom)).toBe(true)
|
||||
expect(store.get(sourceCanGoNextAtom)).toBe(true)
|
||||
|
||||
store.set(state.continueFromSourceAtom, {
|
||||
store.set(continueFromSourceAtom, {
|
||||
defaultDslAppName: 'Imported DSL',
|
||||
defaultReleaseName: 'Initial Release',
|
||||
})
|
||||
|
||||
expect(store.get(state.selectedAppAtom)).toMatchObject({
|
||||
expect(store.get(selectedAppAtom)).toMatchObject({
|
||||
id: 'source-app-1',
|
||||
name: 'Customer Service',
|
||||
})
|
||||
expect(store.get(state.instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/)
|
||||
expect(store.get(state.releaseNameAtom)).toBe('Initial Release')
|
||||
expect(store.get(state.stepAtom)).toBe('release')
|
||||
expect(store.get(instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/)
|
||||
expect(store.get(releaseNameAtom)).toBe('Initial Release')
|
||||
expect(store.get(stepAtom)).toBe('release')
|
||||
})
|
||||
|
||||
it('should read selected DSL file content through the file content query', async () => {
|
||||
const state = await loadState()
|
||||
it('should read selected DSL file content through the file content query', () => {
|
||||
const store = createStore()
|
||||
const text = vi.fn().mockResolvedValue(workflowDsl())
|
||||
const file = new File([], 'workflow.yml', { type: 'text/yaml' })
|
||||
Object.defineProperty(file, 'text', { value: text })
|
||||
|
||||
store.set(state.selectDslFileAtom, file)
|
||||
store.set(selectDslFileAtom, file)
|
||||
mockQueryResults.current.set('createGuideDslFileContent', {
|
||||
data: workflowDsl(),
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
expect(text).not.toHaveBeenCalled()
|
||||
expect(store.get(state.dslFileAtom)).toBe(file)
|
||||
expect(store.get(state.dslDefaultAppNameAtom)).toBe('Imported guide')
|
||||
expect(store.get(state.isReadingDslAtom)).toBe(false)
|
||||
expect(store.get(state.dslReadErrorAtom)).toBe(false)
|
||||
expect(store.get(dslFileAtom)).toBe(file)
|
||||
expect(store.get(dslDefaultAppNameAtom)).toBe('Imported guide')
|
||||
expect(store.get(isReadingDslAtom)).toBe(false)
|
||||
expect(store.get(dslReadErrorAtom)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,933 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
DeployRequest,
|
||||
EnvVarInput,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
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/shared/domain/error'
|
||||
import type { App } from '@/types/app'
|
||||
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
|
||||
import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/components/env-var-bindings-utils'
|
||||
import {
|
||||
hasMissingRequiredRuntimeCredentialBinding,
|
||||
runtimeCredentialSlotKey,
|
||||
selectedDeploymentRuntimeCredentials,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '@/features/deployments/components/runtime-credential-bindings-utils'
|
||||
import {
|
||||
dslAppName,
|
||||
dslEnvVarSlots,
|
||||
encodeDslContent,
|
||||
isWorkflowDsl,
|
||||
} from '@/features/deployments/shared/domain/dsl'
|
||||
import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error'
|
||||
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
|
||||
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'
|
||||
export type WorkflowSourceApp = App & { mode: Extract<AppModeEnum, 'workflow'> }
|
||||
|
||||
function deploymentGuideMethod(method: GuideMethod): GuideMethod {
|
||||
return method === 'importDsl' && !isDeploymentDslImportEnabled
|
||||
? 'bindApp'
|
||||
: method
|
||||
}
|
||||
|
||||
const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
|
||||
const RANDOM_SUFFIX_LENGTH = 4
|
||||
const RANDOM_SUFFIX_FALLBACK_LENGTH = 6
|
||||
const RANDOM_SUFFIX_MAX_ATTEMPTS = 16
|
||||
|
||||
function randomLetterCombination(length: number) {
|
||||
const randomValues = new Uint8Array(length)
|
||||
|
||||
if (globalThis.crypto) {
|
||||
globalThis.crypto.getRandomValues(randomValues)
|
||||
}
|
||||
else {
|
||||
randomValues.forEach((_, index) => {
|
||||
randomValues[index] = Math.floor(Math.random() * 256)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('')
|
||||
}
|
||||
|
||||
function availableInstanceName(sourceName: string, existingNameSet: Set<string>) {
|
||||
if (!existingNameSet.has(sourceName))
|
||||
return sourceName
|
||||
|
||||
for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) {
|
||||
const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}`
|
||||
if (!existingNameSet.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}`
|
||||
}
|
||||
|
||||
function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
|
||||
return selection?.valueSource
|
||||
?? (slot.hasDefaultValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
|
||||
: slot.hasLastValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
|
||||
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
|
||||
}
|
||||
|
||||
function envVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
|
||||
const valueSource = envVarValueSource(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))
|
||||
}
|
||||
|
||||
function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] {
|
||||
const valueSource = envVarValueSource(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,
|
||||
}]
|
||||
}
|
||||
|
||||
// Workflow primitives
|
||||
export const stepAtom = atom<GuideStep>('source')
|
||||
export const methodAtom = atom<GuideMethod>('bindApp')
|
||||
export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom)))
|
||||
|
||||
// Source primitives
|
||||
export const sourceSearchTextAtom = atom('')
|
||||
export const selectedAppAtom = atom<WorkflowSourceApp | undefined>(undefined)
|
||||
|
||||
// DSL primitives and derived state
|
||||
export const dslFileAtom = atom<File | undefined>(undefined)
|
||||
const dslFileReadVersionAtom = atom(0)
|
||||
|
||||
const dslFileContentQueryAtom = atomWithQuery((get) => {
|
||||
const file = get(dslFileAtom)
|
||||
const fileReadVersion = get(dslFileReadVersionAtom)
|
||||
|
||||
return queryOptions({
|
||||
queryKey: [
|
||||
'createGuideDslFileContent',
|
||||
fileReadVersion,
|
||||
file,
|
||||
file?.name ?? '',
|
||||
file?.size ?? 0,
|
||||
file?.lastModified ?? 0,
|
||||
],
|
||||
queryFn: async () => file ? await file.text() : '',
|
||||
enabled: Boolean(file),
|
||||
retry: false,
|
||||
})
|
||||
})
|
||||
|
||||
const dslContentAtom = atom((get) => {
|
||||
return get(dslFileContentQueryAtom).data ?? ''
|
||||
})
|
||||
|
||||
export const isReadingDslAtom = atom((get) => {
|
||||
const file = get(dslFileAtom)
|
||||
const dslFileContentQuery = get(dslFileContentQueryAtom)
|
||||
|
||||
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
|
||||
})
|
||||
|
||||
export const dslReadErrorAtom = atom((get) => {
|
||||
return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError)
|
||||
})
|
||||
|
||||
export const dslDefaultAppNameAtom = atom((get) => {
|
||||
const dslContent = get(dslContentAtom)
|
||||
|
||||
return dslContent ? dslAppName(dslContent) : ''
|
||||
})
|
||||
|
||||
export const dslUnsupportedModeAtom = atom((get) => {
|
||||
const dslContent = get(dslContentAtom)
|
||||
|
||||
return get(effectiveMethodAtom) === 'importDsl'
|
||||
&& Boolean(dslContent.trim())
|
||||
&& !get(isReadingDslAtom)
|
||||
&& !get(dslReadErrorAtom)
|
||||
&& !isWorkflowDsl(dslContent)
|
||||
})
|
||||
|
||||
const importDslReadyAtom = atom((get) => {
|
||||
return Boolean(get(dslContentAtom).trim())
|
||||
&& !get(isReadingDslAtom)
|
||||
&& !get(dslReadErrorAtom)
|
||||
&& !get(dslUnsupportedModeAtom)
|
||||
})
|
||||
|
||||
// Release primitives
|
||||
export const instanceNameAtom = atom('')
|
||||
export const instanceDescriptionAtom = atom('')
|
||||
export const releaseNameAtom = atom('')
|
||||
export const releaseDescriptionAtom = atom('')
|
||||
const autoFilledInstanceNameAtom = atom('')
|
||||
const autoFilledReleaseNameAtom = atom('')
|
||||
|
||||
// Target primitives
|
||||
export const selectedEnvironmentIdAtom = atom('')
|
||||
const manualBindingSelectionsAtom = atom<RuntimeCredentialBindingSelections>({})
|
||||
export const envVarValuesAtom = atom<EnvVarValues>({})
|
||||
|
||||
// Submission primitives
|
||||
const submissionUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([])
|
||||
const isCreatingDeploymentAtom = atom(false)
|
||||
export const isCreatingReleaseOnlyAtom = atom(false)
|
||||
|
||||
export const isSubmittingDeploymentGuideAtom = atom(get => (
|
||||
get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom)
|
||||
))
|
||||
|
||||
// Query and remote data
|
||||
export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
|
||||
const sourceSearchText = get(sourceSearchTextAtom)
|
||||
|
||||
return consoleQuery.apps.list.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
page: Number(pageParam),
|
||||
limit: SOURCE_APPS_PAGE_SIZE,
|
||||
name: sourceSearchText,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: get(effectiveMethodAtom) === 'bindApp',
|
||||
})
|
||||
})
|
||||
|
||||
export const effectiveSelectedAppAtom = atom((get) => {
|
||||
const selectedApp = get(selectedAppAtom)
|
||||
if (selectedApp)
|
||||
return selectedApp
|
||||
|
||||
const sourceAppsQuery = get(sourceAppsQueryAtom)
|
||||
if (sourceAppsQuery.isPlaceholderData)
|
||||
return undefined
|
||||
|
||||
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
|
||||
|
||||
return sourceApps[0]
|
||||
})
|
||||
|
||||
function sourceReady(get: Getter) {
|
||||
const method = get(effectiveMethodAtom)
|
||||
|
||||
return method === 'importDsl'
|
||||
? get(importDslReadyAtom)
|
||||
: Boolean(get(effectiveSelectedAppAtom)?.id)
|
||||
}
|
||||
|
||||
const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() =>
|
||||
consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
pageNumber: Number(pageParam),
|
||||
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
)
|
||||
|
||||
const instanceNameConflictQueryAtom = atomWithQuery((get) => {
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
|
||||
return consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 1,
|
||||
displayName: submittedInstanceName,
|
||||
},
|
||||
},
|
||||
enabled: Boolean(submittedInstanceName),
|
||||
})
|
||||
})
|
||||
|
||||
export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => {
|
||||
return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
// The guide offers every deployable environment at once; environment
|
||||
// count is capped well below the 100-per-page maximum.
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 100,
|
||||
},
|
||||
},
|
||||
enabled: sourceReady(get),
|
||||
})
|
||||
})
|
||||
|
||||
const precheckReleaseQueryAtom = atomWithQuery((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const dslContent = get(dslContentAtom)
|
||||
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
|
||||
const enabled = sourceReady(get)
|
||||
|
||||
// PrecheckRelease takes exactly one source arm (dsl | sourceAppId).
|
||||
const precheckReleaseQueryOptions = method === 'importDsl'
|
||||
? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
|
||||
input: encodedDslContent
|
||||
? {
|
||||
body: {
|
||||
dsl: encodedDslContent,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled,
|
||||
retry: false,
|
||||
})
|
||||
: consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
|
||||
input: effectiveSelectedApp?.id
|
||||
? {
|
||||
body: {
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled: enabled && Boolean(effectiveSelectedApp?.id),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
return precheckReleaseQueryOptions
|
||||
})
|
||||
|
||||
function precheckReleaseReady(get: Getter) {
|
||||
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
&& precheckReleaseQuery.isSuccess
|
||||
&& Boolean(precheckReleaseQuery.data?.canCreate)
|
||||
&& (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0
|
||||
&& get(submissionUnsupportedDslNodesAtom).length === 0
|
||||
}
|
||||
|
||||
export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const dslContent = get(dslContentAtom)
|
||||
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
|
||||
const enabled = precheckReleaseReady(get)
|
||||
|
||||
// ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId).
|
||||
const deploymentOptionsQueryOptions = method === 'importDsl'
|
||||
? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
|
||||
input: encodedDslContent
|
||||
? {
|
||||
body: {
|
||||
dsl: encodedDslContent,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled,
|
||||
retry: false,
|
||||
})
|
||||
: consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
|
||||
input: effectiveSelectedApp?.id
|
||||
? {
|
||||
body: {
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled: enabled && Boolean(effectiveSelectedApp?.id),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
return deploymentOptionsQueryOptions
|
||||
})
|
||||
|
||||
// Unsupported DSL state
|
||||
export const unsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => {
|
||||
const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom)
|
||||
if (submissionUnsupportedDslNodes.length > 0)
|
||||
return submissionUnsupportedDslNodes
|
||||
|
||||
if (!sourceReady(get))
|
||||
return []
|
||||
|
||||
return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []
|
||||
})
|
||||
|
||||
const precheckReleaseReadyAtom = atom((get) => {
|
||||
return precheckReleaseReady(get)
|
||||
})
|
||||
|
||||
const deploymentOptionsReadyAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
&& get(precheckReleaseReadyAtom)
|
||||
&& deploymentOptionsQuery.isSuccess
|
||||
})
|
||||
|
||||
const deploymentOptionsContentCheckedAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
|
||||
const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
|
||||
const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data)
|
||||
|
||||
if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions)
|
||||
return false
|
||||
|
||||
return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess
|
||||
})
|
||||
|
||||
export const sourceCanGoNextAtom = atom((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const importDslReady = method === 'importDsl' && get(importDslReadyAtom)
|
||||
const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id)
|
||||
|
||||
return (importDslReady || bindAppReady) && get(deploymentOptionsContentCheckedAtom)
|
||||
})
|
||||
|
||||
export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => {
|
||||
if (get(sourceSearchTextAtom) === value)
|
||||
return
|
||||
|
||||
set(sourceSearchTextAtom, value)
|
||||
set(selectedAppAtom, undefined)
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
})
|
||||
|
||||
export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp) => {
|
||||
set(selectedAppAtom, app)
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
})
|
||||
|
||||
export const continueFromSourceAtom = atom(null, (get, set, {
|
||||
defaultDslAppName,
|
||||
defaultReleaseName,
|
||||
}: {
|
||||
defaultDslAppName: string
|
||||
defaultReleaseName: string
|
||||
}) => {
|
||||
if (!get(sourceCanGoNextAtom))
|
||||
return
|
||||
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
if (method === 'bindApp' && effectiveSelectedApp)
|
||||
set(selectSourceAppAtom, effectiveSelectedApp)
|
||||
|
||||
const sourceName = method === 'importDsl'
|
||||
? get(dslDefaultAppNameAtom) || defaultDslAppName
|
||||
: effectiveSelectedApp?.name
|
||||
const nextInstanceName = sourceName?.trim()
|
||||
|
||||
if (nextInstanceName) {
|
||||
const currentInstanceName = get(instanceNameAtom).trim()
|
||||
const autoFilledInstanceName = get(autoFilledInstanceNameAtom)
|
||||
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
|
||||
const existingNameSet = new Set(
|
||||
existingInstanceNamesQuery.data?.pages.flatMap(page =>
|
||||
page.appInstances.flatMap((appInstance) => {
|
||||
const name = appInstance.displayName.trim()
|
||||
|
||||
return name ? [name] : []
|
||||
}),
|
||||
) ?? [],
|
||||
)
|
||||
|
||||
if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) {
|
||||
const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet)
|
||||
set(instanceNameAtom, nextAvailableInstanceName)
|
||||
set(autoFilledInstanceNameAtom, nextAvailableInstanceName)
|
||||
}
|
||||
}
|
||||
|
||||
const currentReleaseName = get(releaseNameAtom).trim()
|
||||
const autoFilledReleaseName = get(autoFilledReleaseNameAtom)
|
||||
if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) {
|
||||
set(releaseNameAtom, defaultReleaseName)
|
||||
set(autoFilledReleaseNameAtom, defaultReleaseName)
|
||||
}
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
// DSL actions
|
||||
export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => {
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
|
||||
set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1)
|
||||
set(dslFileAtom, dslFile)
|
||||
})
|
||||
|
||||
// Release derived state and actions
|
||||
export const hasInstanceNameConflictAtom = atom((get) => {
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
|
||||
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
|
||||
const existingInstanceNames = existingInstanceNamesQuery.data?.pages.flatMap(page =>
|
||||
page.appInstances.flatMap((appInstance) => {
|
||||
const name = appInstance.displayName.trim()
|
||||
|
||||
return name ? [name] : []
|
||||
}),
|
||||
) ?? []
|
||||
|
||||
return Boolean(
|
||||
submittedInstanceName
|
||||
&& (
|
||||
existingInstanceNames.includes(submittedInstanceName)
|
||||
|| (instanceNameConflictQuery.data?.appInstances.some(appInstance => appInstance.displayName.trim() === submittedInstanceName) ?? false)
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const submittedReleaseReadyAtom = atom((get) => {
|
||||
return Boolean(sourceReady(get) && get(instanceNameAtom).trim() && get(releaseNameAtom).trim())
|
||||
})
|
||||
|
||||
export const releaseCanGoNextAtom = atom((get) => {
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
|
||||
|
||||
return Boolean(get(submittedReleaseReadyAtom))
|
||||
&& !get(hasInstanceNameConflictAtom)
|
||||
&& !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading)
|
||||
&& get(deploymentOptionsContentCheckedAtom)
|
||||
})
|
||||
|
||||
export const setInstanceNameAtom = atom(null, (_get, set, value: string) => {
|
||||
set(instanceNameAtom, value)
|
||||
set(autoFilledInstanceNameAtom, '')
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const setInstanceDescriptionAtom = atom(null, (_get, set, value: string) => {
|
||||
set(instanceDescriptionAtom, value)
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const setReleaseNameAtom = atom(null, (_get, set, value: string) => {
|
||||
set(releaseNameAtom, value)
|
||||
set(autoFilledReleaseNameAtom, '')
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) => {
|
||||
set(releaseDescriptionAtom, value)
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const continueFromReleaseAtom = atom(null, (get, set) => {
|
||||
if (!get(releaseCanGoNextAtom))
|
||||
return
|
||||
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(stepAtom, 'target')
|
||||
})
|
||||
|
||||
// Target derived state and actions
|
||||
export const deployableEnvironmentsAtom = atom((get) => {
|
||||
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
? deployableEnvironmentsQuery.data?.environments ?? []
|
||||
: []
|
||||
})
|
||||
|
||||
const deployableEnvironmentsReadyAtom = atom((get) => {
|
||||
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
|
||||
|
||||
return sourceReady(get) && deployableEnvironmentsQuery.isSuccess
|
||||
})
|
||||
|
||||
export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
|
||||
return get(selectedEnvironmentIdAtom) || get(deployableEnvironmentsAtom)[0]?.id
|
||||
})
|
||||
|
||||
export const deploymentTargetBindingSlotsAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
|
||||
: []
|
||||
})
|
||||
|
||||
export const deploymentTargetBindingSelectionsAtom = atom((get) => {
|
||||
return selectedRuntimeCredentialSelections(
|
||||
get(deploymentTargetBindingSlotsAtom),
|
||||
get(manualBindingSelectionsAtom),
|
||||
)
|
||||
})
|
||||
|
||||
const requiredBindingsReadyAtom = atom((get) => {
|
||||
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
|
||||
|
||||
return get(deploymentTargetBindingSlotsAtom).every(slot =>
|
||||
!hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]),
|
||||
)
|
||||
})
|
||||
|
||||
export const deploymentTargetEnvVarSlotsAtom = atom((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined
|
||||
const dslContent = get(dslContentAtom)
|
||||
|
||||
// Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults.
|
||||
const deploymentOptionEnvVarSlots = slots?.flatMap((slot): EnvVarBindingSlot[] => {
|
||||
const bindingSlot = envVarBindingSlotFromContract(slot)
|
||||
return bindingSlot ? [bindingSlot] : []
|
||||
}) ?? []
|
||||
const dslEnvVarMetadataSlots = method === 'importDsl' && dslContent
|
||||
? dslEnvVarSlots(dslContent).flatMap((slot) => {
|
||||
const key = slot.key.trim()
|
||||
if (!key)
|
||||
return []
|
||||
|
||||
return [{
|
||||
key,
|
||||
...(slot.description ? { description: slot.description } : {}),
|
||||
...(slot.defaultValue !== undefined ? { defaultValue: slot.defaultValue, hasDefaultValue: true } : {}),
|
||||
...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}),
|
||||
}]
|
||||
})
|
||||
: []
|
||||
|
||||
if (dslEnvVarMetadataSlots.length === 0)
|
||||
return deploymentOptionEnvVarSlots
|
||||
|
||||
const metadataByKey = new Map(
|
||||
dslEnvVarMetadataSlots.map(slot => [slot.key, slot] as const),
|
||||
)
|
||||
|
||||
return deploymentOptionEnvVarSlots.map((slot) => {
|
||||
const metadata = metadataByKey.get(slot.key)
|
||||
if (!metadata)
|
||||
return slot
|
||||
|
||||
const nextSlot = { ...slot }
|
||||
|
||||
if (!nextSlot.description && metadata.description)
|
||||
nextSlot.description = metadata.description
|
||||
if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) {
|
||||
nextSlot.defaultValue = metadata.defaultValue
|
||||
nextSlot.hasDefaultValue = true
|
||||
}
|
||||
if (nextSlot.valueType === 'string' && metadata.valueType)
|
||||
nextSlot.valueType = metadata.valueType
|
||||
|
||||
return nextSlot
|
||||
})
|
||||
})
|
||||
|
||||
const requiredEnvVarsReadyAtom = atom((get) => {
|
||||
const envVarValues = get(envVarValuesAtom)
|
||||
|
||||
return get(deploymentTargetEnvVarSlotsAtom).every(slot =>
|
||||
envVarSelectionReady(slot, envVarValues[slot.key]),
|
||||
)
|
||||
})
|
||||
|
||||
export const canDeployAtom = atom((get) => {
|
||||
const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom)
|
||||
const selectedEnvironment = effectiveSelectedEnvironmentId
|
||||
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
|
||||
: undefined
|
||||
|
||||
return Boolean(
|
||||
selectedEnvironment?.id
|
||||
&& get(deployableEnvironmentsReadyAtom)
|
||||
&& get(deploymentOptionsReadyAtom)
|
||||
&& get(requiredBindingsReadyAtom)
|
||||
&& get(requiredEnvVarsReadyAtom)
|
||||
&& get(submittedReleaseReadyAtom),
|
||||
)
|
||||
})
|
||||
|
||||
export const canSkipDeploymentAtom = atom((get) => {
|
||||
return get(submittedReleaseReadyAtom) && get(deploymentOptionsReadyAtom)
|
||||
})
|
||||
|
||||
export const selectBindingAtom = atom(null, (get, set, slot: string, value: string) => {
|
||||
set(manualBindingSelectionsAtom, {
|
||||
...get(manualBindingSelectionsAtom),
|
||||
[slot]: value,
|
||||
})
|
||||
})
|
||||
|
||||
export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => {
|
||||
set(envVarValuesAtom, {
|
||||
...get(envVarValuesAtom),
|
||||
[key]: value,
|
||||
})
|
||||
})
|
||||
|
||||
// Workflow actions
|
||||
export const selectMethodAtom = atom(null, (_get, set, method: GuideMethod) => {
|
||||
set(methodAtom, deploymentGuideMethod(method))
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
set(stepAtom, 'source')
|
||||
})
|
||||
|
||||
// Submission
|
||||
const createAppInstanceMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions(),
|
||||
)
|
||||
|
||||
const createReleaseMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.releaseService.createRelease.mutationOptions(),
|
||||
)
|
||||
|
||||
const createInitialDeploymentMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.deploymentService.deploy.mutationOptions(),
|
||||
)
|
||||
|
||||
export class CreateDeploymentGuideSubmissionBlockedError extends Error {
|
||||
reason: 'unsupportedDslMode' | 'deployFailed'
|
||||
|
||||
constructor(reason: 'unsupportedDslMode' | 'deployFailed') {
|
||||
super(reason)
|
||||
this.reason = reason
|
||||
this.name = 'CreateDeploymentGuideSubmissionBlockedError'
|
||||
}
|
||||
}
|
||||
|
||||
export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, {
|
||||
deployToEnvironment,
|
||||
}: {
|
||||
deployToEnvironment: boolean
|
||||
}) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const dslContent = get(dslContentAtom)
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
const submittedReleaseName = get(releaseNameAtom).trim()
|
||||
const submittedReleaseDescription = get(releaseDescriptionAtom).trim()
|
||||
|
||||
if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom))
|
||||
return undefined
|
||||
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
|
||||
const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options
|
||||
const envVarSlots = get(deploymentTargetEnvVarSlotsAtom)
|
||||
const envVarValues = get(envVarValuesAtom)
|
||||
const bindingSlots = get(deploymentTargetBindingSlotsAtom)
|
||||
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
|
||||
const selectedEnvironmentId = get(selectedEnvironmentIdAtom)
|
||||
const effectiveSelectedEnvironmentId = selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id
|
||||
const selectedEnvironment = effectiveSelectedEnvironmentId
|
||||
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
|
||||
: undefined
|
||||
|
||||
if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim())
|
||||
return undefined
|
||||
if (method === 'bindApp' && !effectiveSelectedApp?.id)
|
||||
return undefined
|
||||
if (method === 'importDsl' && !dslContent.trim())
|
||||
return undefined
|
||||
if (method === 'importDsl' && !isWorkflowDsl(dslContent))
|
||||
throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode')
|
||||
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
|
||||
try {
|
||||
if (!deployToEnvironment) {
|
||||
if (!get(canSkipDeploymentAtom))
|
||||
return undefined
|
||||
|
||||
set(isCreatingReleaseOnlyAtom, true)
|
||||
|
||||
try {
|
||||
const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({
|
||||
body: {
|
||||
displayName: submittedInstanceName,
|
||||
description: get(instanceDescriptionAtom).trim() || undefined,
|
||||
},
|
||||
})
|
||||
const appInstanceId = createdAppInstance.appInstance.id
|
||||
|
||||
if (method === 'importDsl') {
|
||||
await get(createReleaseMutationAtom).mutateAsync({
|
||||
body: {
|
||||
appInstanceId,
|
||||
dsl: encodeDslContent(dslContent),
|
||||
displayName: submittedReleaseName,
|
||||
description: submittedReleaseDescription || undefined,
|
||||
createAppInstance: false,
|
||||
},
|
||||
})
|
||||
|
||||
return appInstanceId
|
||||
}
|
||||
|
||||
if (!effectiveSelectedApp?.id)
|
||||
return undefined
|
||||
|
||||
await get(createReleaseMutationAtom).mutateAsync({
|
||||
body: {
|
||||
appInstanceId,
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
displayName: submittedReleaseName,
|
||||
description: submittedReleaseDescription || undefined,
|
||||
createAppInstance: false,
|
||||
},
|
||||
})
|
||||
|
||||
return appInstanceId
|
||||
}
|
||||
finally {
|
||||
set(isCreatingReleaseOnlyAtom, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!get(canDeployAtom))
|
||||
return undefined
|
||||
|
||||
set(isCreatingDeploymentAtom, true)
|
||||
|
||||
try {
|
||||
const selectedEnvironmentIdentifier = selectedEnvironmentId.trim()
|
||||
const freshSelectedEnvironment = selectedEnvironment || (
|
||||
selectedEnvironmentIdentifier
|
||||
? (await deployableEnvironmentsQuery.refetch()).data?.environments.find(environment =>
|
||||
environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier),
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
const targetEnvironmentId = freshSelectedEnvironment?.id
|
||||
if (!targetEnvironmentId)
|
||||
throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed')
|
||||
|
||||
if (!get(requiredBindingsReadyAtom))
|
||||
throw new Error('Missing required deployment binding.')
|
||||
if (!get(requiredEnvVarsReadyAtom))
|
||||
throw new Error('Missing required deployment environment variable.')
|
||||
|
||||
const envVars = envVarSlots.flatMap(slot => envVarInput(slot, envVarValues[slot.key]))
|
||||
const commonDeploymentRequest = {
|
||||
newAppInstance: {
|
||||
displayName: submittedInstanceName,
|
||||
description: get(instanceDescriptionAtom).trim() || undefined,
|
||||
},
|
||||
environmentId: targetEnvironmentId,
|
||||
releaseName: submittedReleaseName,
|
||||
releaseDescription: submittedReleaseDescription || undefined,
|
||||
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
|
||||
envVars,
|
||||
idempotencyKey: createDeploymentIdempotencyKey(),
|
||||
expectedDslDigest: deploymentOptions?.dslDigest,
|
||||
} satisfies Omit<DeployRequest, 'dsl' | 'sourceAppId'>
|
||||
const deploymentRequest = method === 'importDsl'
|
||||
? {
|
||||
...commonDeploymentRequest,
|
||||
dsl: encodeDslContent(dslContent),
|
||||
}
|
||||
: effectiveSelectedApp?.id
|
||||
? {
|
||||
...commonDeploymentRequest,
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
}
|
||||
: undefined
|
||||
if (!deploymentRequest)
|
||||
return undefined
|
||||
|
||||
const response = await get(createInitialDeploymentMutationAtom).mutateAsync({
|
||||
body: deploymentRequest,
|
||||
})
|
||||
|
||||
return response.appInstance.id
|
||||
}
|
||||
finally {
|
||||
set(isCreatingDeploymentAtom, false)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const unsupportedError = await unsupportedDslNodeError(error)
|
||||
if (unsupportedError?.nodes.length) {
|
||||
set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes)
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
// Scoped local state
|
||||
export const createDeploymentGuideScopedAtoms = [
|
||||
stepAtom,
|
||||
methodAtom,
|
||||
sourceSearchTextAtom,
|
||||
selectedAppAtom,
|
||||
dslFileAtom,
|
||||
dslFileReadVersionAtom,
|
||||
instanceNameAtom,
|
||||
instanceDescriptionAtom,
|
||||
releaseNameAtom,
|
||||
releaseDescriptionAtom,
|
||||
autoFilledInstanceNameAtom,
|
||||
autoFilledReleaseNameAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
manualBindingSelectionsAtom,
|
||||
envVarValuesAtom,
|
||||
submissionUnsupportedDslNodesAtom,
|
||||
isCreatingDeploymentAtom,
|
||||
isCreatingReleaseOnlyAtom,
|
||||
]
|
||||
37
web/features/deployments/create-guide/state/primitives.ts
Normal file
37
web/features/deployments/create-guide/state/primitives.ts
Normal file
@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import type { GuideMethod, GuideStep, WorkflowSourceApp } from './types'
|
||||
import type { EnvVarValues } from '@/features/deployments/shared/components/env-var-bindings'
|
||||
import type { RuntimeCredentialBindingSelections } from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
|
||||
import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error'
|
||||
import { atom } from 'jotai'
|
||||
import { deploymentGuideMethod } from './utils'
|
||||
|
||||
export const stepAtom = atom<GuideStep>('source')
|
||||
export const methodAtom = atom<GuideMethod>('bindApp')
|
||||
export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom)))
|
||||
|
||||
export const sourceSearchTextAtom = atom('')
|
||||
export const selectedAppAtom = atom<WorkflowSourceApp | undefined>(undefined)
|
||||
|
||||
export const dslFileAtom = atom<File | undefined>(undefined)
|
||||
export const dslFileReadVersionAtom = atom(0)
|
||||
|
||||
export const instanceNameAtom = atom('')
|
||||
export const instanceDescriptionAtom = atom('')
|
||||
export const releaseNameAtom = atom('')
|
||||
export const releaseDescriptionAtom = atom('')
|
||||
export const autoFilledInstanceNameAtom = atom('')
|
||||
export const autoFilledReleaseNameAtom = atom('')
|
||||
|
||||
export const selectedEnvironmentIdAtom = atom('')
|
||||
export const manualBindingSelectionsAtom = atom<RuntimeCredentialBindingSelections>({})
|
||||
export const envVarValuesAtom = atom<EnvVarValues>({})
|
||||
|
||||
export const submissionUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([])
|
||||
export const isCreatingDeploymentAtom = atom(false)
|
||||
export const isCreatingReleaseOnlyAtom = atom(false)
|
||||
|
||||
export const isSubmittingDeploymentGuideAtom = atom(get => (
|
||||
get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom)
|
||||
))
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { createDeploymentGuideScopedAtoms } from './index'
|
||||
import { createDeploymentGuideScopedAtoms } from './scoped'
|
||||
|
||||
export function CreateDeploymentGuideProvider({ children }: {
|
||||
children: ReactNode
|
||||
|
||||
169
web/features/deployments/create-guide/state/queries.ts
Normal file
169
web/features/deployments/create-guide/state/queries.ts
Normal file
@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import type { Getter } from 'jotai/vanilla'
|
||||
import { keepPreviousData, skipToken } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { encodeDslContent } from '@/features/deployments/shared/domain/dsl'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { effectiveMethodAtom, instanceNameAtom, submissionUnsupportedDslNodesAtom } from './primitives'
|
||||
import { dslContentAtom, effectiveSelectedAppAtom, sourceReady } from './source'
|
||||
import { DEPLOYMENT_PAGE_SIZE, getNextPageParamFromPagination } from './utils'
|
||||
|
||||
export const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() =>
|
||||
consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
pageNumber: Number(pageParam),
|
||||
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
)
|
||||
|
||||
export const instanceNameConflictQueryAtom = atomWithQuery((get) => {
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
|
||||
return consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 1,
|
||||
displayName: submittedInstanceName,
|
||||
},
|
||||
},
|
||||
enabled: Boolean(submittedInstanceName),
|
||||
})
|
||||
})
|
||||
|
||||
export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => {
|
||||
return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
// The guide offers every deployable environment at once; environment
|
||||
// count is capped well below the 100-per-page maximum.
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 100,
|
||||
},
|
||||
},
|
||||
enabled: sourceReady(get),
|
||||
})
|
||||
})
|
||||
|
||||
const precheckReleaseQueryAtom = atomWithQuery((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const dslContent = get(dslContentAtom)
|
||||
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
|
||||
const enabled = sourceReady(get)
|
||||
|
||||
// PrecheckRelease takes exactly one source arm (dsl | sourceAppId).
|
||||
const precheckReleaseQueryOptions = method === 'importDsl'
|
||||
? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
|
||||
input: encodedDslContent
|
||||
? {
|
||||
body: {
|
||||
dsl: encodedDslContent,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled,
|
||||
retry: false,
|
||||
})
|
||||
: consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
|
||||
input: effectiveSelectedApp?.id
|
||||
? {
|
||||
body: {
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled: enabled && Boolean(effectiveSelectedApp?.id),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
return precheckReleaseQueryOptions
|
||||
})
|
||||
|
||||
function precheckReleaseReady(get: Getter) {
|
||||
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
&& precheckReleaseQuery.isSuccess
|
||||
&& Boolean(precheckReleaseQuery.data?.canCreate)
|
||||
&& (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0
|
||||
&& get(submissionUnsupportedDslNodesAtom).length === 0
|
||||
}
|
||||
|
||||
export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const dslContent = get(dslContentAtom)
|
||||
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
|
||||
const enabled = precheckReleaseReady(get)
|
||||
|
||||
// ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId).
|
||||
const deploymentOptionsQueryOptions = method === 'importDsl'
|
||||
? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
|
||||
input: encodedDslContent
|
||||
? {
|
||||
body: {
|
||||
dsl: encodedDslContent,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled,
|
||||
retry: false,
|
||||
})
|
||||
: consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
|
||||
input: effectiveSelectedApp?.id
|
||||
? {
|
||||
body: {
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled: enabled && Boolean(effectiveSelectedApp?.id),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
return deploymentOptionsQueryOptions
|
||||
})
|
||||
|
||||
export const unsupportedDslNodesAtom = atom((get) => {
|
||||
const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom)
|
||||
if (submissionUnsupportedDslNodes.length > 0)
|
||||
return submissionUnsupportedDslNodes
|
||||
|
||||
if (!sourceReady(get))
|
||||
return []
|
||||
|
||||
return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []
|
||||
})
|
||||
|
||||
const precheckReleaseReadyAtom = atom((get) => {
|
||||
return precheckReleaseReady(get)
|
||||
})
|
||||
|
||||
export const deploymentOptionsReadyAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
&& get(precheckReleaseReadyAtom)
|
||||
&& deploymentOptionsQuery.isSuccess
|
||||
})
|
||||
|
||||
export const deploymentOptionsContentCheckedAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
|
||||
const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
|
||||
const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data)
|
||||
|
||||
if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions)
|
||||
return false
|
||||
|
||||
return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess
|
||||
})
|
||||
84
web/features/deployments/create-guide/state/release.ts
Normal file
84
web/features/deployments/create-guide/state/release.ts
Normal file
@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { atom } from 'jotai'
|
||||
import {
|
||||
autoFilledInstanceNameAtom,
|
||||
autoFilledReleaseNameAtom,
|
||||
envVarValuesAtom,
|
||||
instanceDescriptionAtom,
|
||||
instanceNameAtom,
|
||||
manualBindingSelectionsAtom,
|
||||
releaseDescriptionAtom,
|
||||
releaseNameAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
stepAtom,
|
||||
} from './primitives'
|
||||
import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom, instanceNameConflictQueryAtom } from './queries'
|
||||
import { sourceReady } from './source'
|
||||
|
||||
export const hasInstanceNameConflictAtom = atom((get) => {
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
|
||||
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
|
||||
const existingInstanceNames = existingInstanceNamesQuery.data?.pages.flatMap(page =>
|
||||
page.appInstances.flatMap((appInstance) => {
|
||||
const name = appInstance.displayName.trim()
|
||||
|
||||
return name ? [name] : []
|
||||
}),
|
||||
) ?? []
|
||||
|
||||
return Boolean(
|
||||
submittedInstanceName
|
||||
&& (
|
||||
existingInstanceNames.includes(submittedInstanceName)
|
||||
|| (instanceNameConflictQuery.data?.appInstances.some(appInstance => appInstance.displayName.trim() === submittedInstanceName) ?? false)
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export const submittedReleaseReadyAtom = atom((get) => {
|
||||
return Boolean(sourceReady(get) && get(instanceNameAtom).trim() && get(releaseNameAtom).trim())
|
||||
})
|
||||
|
||||
export const releaseCanGoNextAtom = atom((get) => {
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
|
||||
|
||||
return Boolean(get(submittedReleaseReadyAtom))
|
||||
&& !get(hasInstanceNameConflictAtom)
|
||||
&& !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading)
|
||||
&& get(deploymentOptionsContentCheckedAtom)
|
||||
})
|
||||
|
||||
export const setInstanceNameAtom = atom(null, (_get, set, value: string) => {
|
||||
set(instanceNameAtom, value)
|
||||
set(autoFilledInstanceNameAtom, '')
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const setInstanceDescriptionAtom = atom(null, (_get, set, value: string) => {
|
||||
set(instanceDescriptionAtom, value)
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const setReleaseNameAtom = atom(null, (_get, set, value: string) => {
|
||||
set(releaseNameAtom, value)
|
||||
set(autoFilledReleaseNameAtom, '')
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) => {
|
||||
set(releaseDescriptionAtom, value)
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const continueFromReleaseAtom = atom(null, (get, set) => {
|
||||
if (!get(releaseCanGoNextAtom))
|
||||
return
|
||||
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(stepAtom, 'target')
|
||||
})
|
||||
41
web/features/deployments/create-guide/state/scoped.ts
Normal file
41
web/features/deployments/create-guide/state/scoped.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
autoFilledInstanceNameAtom,
|
||||
autoFilledReleaseNameAtom,
|
||||
dslFileAtom,
|
||||
dslFileReadVersionAtom,
|
||||
envVarValuesAtom,
|
||||
instanceDescriptionAtom,
|
||||
instanceNameAtom,
|
||||
isCreatingDeploymentAtom,
|
||||
isCreatingReleaseOnlyAtom,
|
||||
manualBindingSelectionsAtom,
|
||||
methodAtom,
|
||||
releaseDescriptionAtom,
|
||||
releaseNameAtom,
|
||||
selectedAppAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
sourceSearchTextAtom,
|
||||
stepAtom,
|
||||
submissionUnsupportedDslNodesAtom,
|
||||
} from './primitives'
|
||||
|
||||
export const createDeploymentGuideScopedAtoms = [
|
||||
stepAtom,
|
||||
methodAtom,
|
||||
sourceSearchTextAtom,
|
||||
selectedAppAtom,
|
||||
dslFileAtom,
|
||||
dslFileReadVersionAtom,
|
||||
instanceNameAtom,
|
||||
instanceDescriptionAtom,
|
||||
releaseNameAtom,
|
||||
releaseDescriptionAtom,
|
||||
autoFilledInstanceNameAtom,
|
||||
autoFilledReleaseNameAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
manualBindingSelectionsAtom,
|
||||
envVarValuesAtom,
|
||||
submissionUnsupportedDslNodesAtom,
|
||||
isCreatingDeploymentAtom,
|
||||
isCreatingReleaseOnlyAtom,
|
||||
]
|
||||
110
web/features/deployments/create-guide/state/source.ts
Normal file
110
web/features/deployments/create-guide/state/source.ts
Normal file
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import type { Getter } from 'jotai/vanilla'
|
||||
import type { WorkflowSourceApp } from './types'
|
||||
import { keepPreviousData, queryOptions } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { dslFileAtom, dslFileReadVersionAtom, effectiveMethodAtom, selectedAppAtom, sourceSearchTextAtom } from './primitives'
|
||||
import { SOURCE_APPS_PAGE_SIZE } from './utils'
|
||||
|
||||
const dslFileContentQueryAtom = atomWithQuery((get) => {
|
||||
const file = get(dslFileAtom)
|
||||
const fileReadVersion = get(dslFileReadVersionAtom)
|
||||
|
||||
return queryOptions({
|
||||
queryKey: [
|
||||
'createGuideDslFileContent',
|
||||
fileReadVersion,
|
||||
file,
|
||||
file?.name ?? '',
|
||||
file?.size ?? 0,
|
||||
file?.lastModified ?? 0,
|
||||
],
|
||||
queryFn: async () => file ? await file.text() : '',
|
||||
enabled: Boolean(file),
|
||||
retry: false,
|
||||
})
|
||||
})
|
||||
|
||||
export const dslContentAtom = atom((get) => {
|
||||
return get(dslFileContentQueryAtom).data ?? ''
|
||||
})
|
||||
|
||||
export const isReadingDslAtom = atom((get) => {
|
||||
const file = get(dslFileAtom)
|
||||
const dslFileContentQuery = get(dslFileContentQueryAtom)
|
||||
|
||||
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
|
||||
})
|
||||
|
||||
export const dslReadErrorAtom = atom((get) => {
|
||||
return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError)
|
||||
})
|
||||
|
||||
export const dslDefaultAppNameAtom = atom((get) => {
|
||||
const dslContent = get(dslContentAtom)
|
||||
|
||||
return dslContent ? dslAppName(dslContent) : ''
|
||||
})
|
||||
|
||||
export const dslUnsupportedModeAtom = atom((get) => {
|
||||
const dslContent = get(dslContentAtom)
|
||||
|
||||
return get(effectiveMethodAtom) === 'importDsl'
|
||||
&& Boolean(dslContent.trim())
|
||||
&& !get(isReadingDslAtom)
|
||||
&& !get(dslReadErrorAtom)
|
||||
&& !isWorkflowDsl(dslContent)
|
||||
})
|
||||
|
||||
export const importDslReadyAtom = atom((get) => {
|
||||
return Boolean(get(dslContentAtom).trim())
|
||||
&& !get(isReadingDslAtom)
|
||||
&& !get(dslReadErrorAtom)
|
||||
&& !get(dslUnsupportedModeAtom)
|
||||
})
|
||||
|
||||
export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
|
||||
const sourceSearchText = get(sourceSearchTextAtom)
|
||||
|
||||
return consoleQuery.apps.list.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
page: Number(pageParam),
|
||||
limit: SOURCE_APPS_PAGE_SIZE,
|
||||
name: sourceSearchText,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: get(effectiveMethodAtom) === 'bindApp',
|
||||
})
|
||||
})
|
||||
|
||||
export const effectiveSelectedAppAtom = atom((get) => {
|
||||
const selectedApp = get(selectedAppAtom)
|
||||
if (selectedApp)
|
||||
return selectedApp
|
||||
|
||||
const sourceAppsQuery = get(sourceAppsQueryAtom)
|
||||
if (sourceAppsQuery.isPlaceholderData)
|
||||
return undefined
|
||||
|
||||
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
|
||||
|
||||
return sourceApps[0]
|
||||
})
|
||||
|
||||
export function sourceReady(get: Getter) {
|
||||
const method = get(effectiveMethodAtom)
|
||||
|
||||
return method === 'importDsl'
|
||||
? get(importDslReadyAtom)
|
||||
: Boolean(get(effectiveSelectedAppAtom)?.id)
|
||||
}
|
||||
221
web/features/deployments/create-guide/state/submission.ts
Normal file
221
web/features/deployments/create-guide/state/submission.ts
Normal file
@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import type { DeployRequest } from '@dify/contracts/enterprise/types.gen'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithMutation } from 'jotai-tanstack-query'
|
||||
import { selectedDeploymentRuntimeCredentials } from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
|
||||
import { encodeDslContent, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
|
||||
import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error'
|
||||
import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentMatchesIdentifier } from './environment'
|
||||
import {
|
||||
effectiveMethodAtom,
|
||||
envVarValuesAtom,
|
||||
instanceDescriptionAtom,
|
||||
instanceNameAtom,
|
||||
isCreatingDeploymentAtom,
|
||||
isCreatingReleaseOnlyAtom,
|
||||
isSubmittingDeploymentGuideAtom,
|
||||
releaseDescriptionAtom,
|
||||
releaseNameAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
submissionUnsupportedDslNodesAtom,
|
||||
} from './primitives'
|
||||
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom } from './queries'
|
||||
import { submittedReleaseReadyAtom } from './release'
|
||||
import { dslContentAtom, effectiveSelectedAppAtom } from './source'
|
||||
import {
|
||||
canDeployAtom,
|
||||
canSkipDeploymentAtom,
|
||||
deployableEnvironmentsAtom,
|
||||
deploymentTargetBindingSelectionsAtom,
|
||||
deploymentTargetBindingSlotsAtom,
|
||||
deploymentTargetEnvVarSlotsAtom,
|
||||
requiredBindingsReadyAtom,
|
||||
requiredEnvVarsReadyAtom,
|
||||
} from './target'
|
||||
import { envVarInput } from './utils'
|
||||
|
||||
const createAppInstanceMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions(),
|
||||
)
|
||||
|
||||
const createReleaseMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.releaseService.createRelease.mutationOptions(),
|
||||
)
|
||||
|
||||
const createInitialDeploymentMutationAtom = atomWithMutation(() =>
|
||||
consoleQuery.enterprise.deploymentService.deploy.mutationOptions(),
|
||||
)
|
||||
|
||||
export class CreateDeploymentGuideSubmissionBlockedError extends Error {
|
||||
reason: 'unsupportedDslMode' | 'deployFailed'
|
||||
|
||||
constructor(reason: 'unsupportedDslMode' | 'deployFailed') {
|
||||
super(reason)
|
||||
this.reason = reason
|
||||
this.name = 'CreateDeploymentGuideSubmissionBlockedError'
|
||||
}
|
||||
}
|
||||
|
||||
export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, {
|
||||
deployToEnvironment,
|
||||
}: {
|
||||
deployToEnvironment: boolean
|
||||
}) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const dslContent = get(dslContentAtom)
|
||||
const submittedInstanceName = get(instanceNameAtom).trim()
|
||||
const submittedReleaseName = get(releaseNameAtom).trim()
|
||||
const submittedReleaseDescription = get(releaseDescriptionAtom).trim()
|
||||
|
||||
if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom))
|
||||
return undefined
|
||||
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
|
||||
const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options
|
||||
const envVarSlots = get(deploymentTargetEnvVarSlotsAtom)
|
||||
const envVarValues = get(envVarValuesAtom)
|
||||
const bindingSlots = get(deploymentTargetBindingSlotsAtom)
|
||||
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
|
||||
const selectedEnvironmentId = get(selectedEnvironmentIdAtom)
|
||||
const effectiveSelectedEnvironmentId = selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id
|
||||
const selectedEnvironment = effectiveSelectedEnvironmentId
|
||||
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
|
||||
: undefined
|
||||
|
||||
if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim())
|
||||
return undefined
|
||||
if (method === 'bindApp' && !effectiveSelectedApp?.id)
|
||||
return undefined
|
||||
if (method === 'importDsl' && !dslContent.trim())
|
||||
return undefined
|
||||
if (method === 'importDsl' && !isWorkflowDsl(dslContent))
|
||||
throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode')
|
||||
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
|
||||
try {
|
||||
if (!deployToEnvironment) {
|
||||
if (!get(canSkipDeploymentAtom))
|
||||
return undefined
|
||||
|
||||
set(isCreatingReleaseOnlyAtom, true)
|
||||
|
||||
try {
|
||||
const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({
|
||||
body: {
|
||||
displayName: submittedInstanceName,
|
||||
description: get(instanceDescriptionAtom).trim() || undefined,
|
||||
},
|
||||
})
|
||||
const appInstanceId = createdAppInstance.appInstance.id
|
||||
|
||||
if (method === 'importDsl') {
|
||||
await get(createReleaseMutationAtom).mutateAsync({
|
||||
body: {
|
||||
appInstanceId,
|
||||
dsl: encodeDslContent(dslContent),
|
||||
displayName: submittedReleaseName,
|
||||
description: submittedReleaseDescription || undefined,
|
||||
createAppInstance: false,
|
||||
},
|
||||
})
|
||||
|
||||
return appInstanceId
|
||||
}
|
||||
|
||||
if (!effectiveSelectedApp?.id)
|
||||
return undefined
|
||||
|
||||
await get(createReleaseMutationAtom).mutateAsync({
|
||||
body: {
|
||||
appInstanceId,
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
displayName: submittedReleaseName,
|
||||
description: submittedReleaseDescription || undefined,
|
||||
createAppInstance: false,
|
||||
},
|
||||
})
|
||||
|
||||
return appInstanceId
|
||||
}
|
||||
finally {
|
||||
set(isCreatingReleaseOnlyAtom, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!get(canDeployAtom))
|
||||
return undefined
|
||||
|
||||
set(isCreatingDeploymentAtom, true)
|
||||
|
||||
try {
|
||||
const selectedEnvironmentIdentifier = selectedEnvironmentId.trim()
|
||||
const freshSelectedEnvironment = selectedEnvironment || (
|
||||
selectedEnvironmentIdentifier
|
||||
? (await deployableEnvironmentsQuery.refetch()).data?.environments.find(environment =>
|
||||
environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier),
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
const targetEnvironmentId = freshSelectedEnvironment?.id
|
||||
if (!targetEnvironmentId)
|
||||
throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed')
|
||||
|
||||
if (!get(requiredBindingsReadyAtom))
|
||||
throw new Error('Missing required deployment binding.')
|
||||
if (!get(requiredEnvVarsReadyAtom))
|
||||
throw new Error('Missing required deployment environment variable.')
|
||||
|
||||
const envVars = envVarSlots.flatMap(slot => envVarInput(slot, envVarValues[slot.key]))
|
||||
const commonDeploymentRequest = {
|
||||
newAppInstance: {
|
||||
displayName: submittedInstanceName,
|
||||
description: get(instanceDescriptionAtom).trim() || undefined,
|
||||
},
|
||||
environmentId: targetEnvironmentId,
|
||||
releaseName: submittedReleaseName,
|
||||
releaseDescription: submittedReleaseDescription || undefined,
|
||||
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
|
||||
envVars,
|
||||
idempotencyKey: createDeploymentIdempotencyKey(),
|
||||
expectedDslDigest: deploymentOptions?.dslDigest,
|
||||
} satisfies Omit<DeployRequest, 'dsl' | 'sourceAppId'>
|
||||
const deploymentRequest = method === 'importDsl'
|
||||
? {
|
||||
...commonDeploymentRequest,
|
||||
dsl: encodeDslContent(dslContent),
|
||||
}
|
||||
: effectiveSelectedApp?.id
|
||||
? {
|
||||
...commonDeploymentRequest,
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
}
|
||||
: undefined
|
||||
if (!deploymentRequest)
|
||||
return undefined
|
||||
|
||||
const response = await get(createInitialDeploymentMutationAtom).mutateAsync({
|
||||
body: deploymentRequest,
|
||||
})
|
||||
|
||||
return response.appInstance.id
|
||||
}
|
||||
finally {
|
||||
set(isCreatingDeploymentAtom, false)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const unsupportedError = await unsupportedDslNodeError(error)
|
||||
if (unsupportedError?.nodes.length) {
|
||||
set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes)
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
})
|
||||
153
web/features/deployments/create-guide/state/target.ts
Normal file
153
web/features/deployments/create-guide/state/target.ts
Normal file
@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings'
|
||||
import { atom } from 'jotai'
|
||||
import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/shared/components/env-var-bindings-utils'
|
||||
import {
|
||||
hasMissingRequiredRuntimeCredentialBinding,
|
||||
runtimeCredentialSlotKey,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
|
||||
import { dslEnvVarSlots } from '@/features/deployments/shared/domain/dsl'
|
||||
import { environmentMatchesIdentifier } from './environment'
|
||||
import { effectiveMethodAtom, envVarValuesAtom, manualBindingSelectionsAtom, selectedEnvironmentIdAtom } from './primitives'
|
||||
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom, deploymentOptionsReadyAtom } from './queries'
|
||||
import { submittedReleaseReadyAtom } from './release'
|
||||
import { dslContentAtom, sourceReady } from './source'
|
||||
import { envVarSelectionReady } from './utils'
|
||||
|
||||
export const deployableEnvironmentsAtom = atom((get) => {
|
||||
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
? deployableEnvironmentsQuery.data?.environments ?? []
|
||||
: []
|
||||
})
|
||||
|
||||
const deployableEnvironmentsReadyAtom = atom((get) => {
|
||||
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
|
||||
|
||||
return sourceReady(get) && deployableEnvironmentsQuery.isSuccess
|
||||
})
|
||||
|
||||
export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
|
||||
return get(selectedEnvironmentIdAtom) || get(deployableEnvironmentsAtom)[0]?.id
|
||||
})
|
||||
|
||||
export const deploymentTargetBindingSlotsAtom = atom((get) => {
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
|
||||
return sourceReady(get)
|
||||
? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
|
||||
: []
|
||||
})
|
||||
|
||||
export const deploymentTargetBindingSelectionsAtom = atom((get) => {
|
||||
return selectedRuntimeCredentialSelections(
|
||||
get(deploymentTargetBindingSlotsAtom),
|
||||
get(manualBindingSelectionsAtom),
|
||||
)
|
||||
})
|
||||
|
||||
export const requiredBindingsReadyAtom = atom((get) => {
|
||||
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
|
||||
|
||||
return get(deploymentTargetBindingSlotsAtom).every(slot =>
|
||||
!hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]),
|
||||
)
|
||||
})
|
||||
|
||||
export const deploymentTargetEnvVarSlotsAtom = atom((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
|
||||
const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined
|
||||
const dslContent = get(dslContentAtom)
|
||||
|
||||
// Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults.
|
||||
const deploymentOptionEnvVarSlots = slots?.flatMap((slot): EnvVarBindingSlot[] => {
|
||||
const bindingSlot = envVarBindingSlotFromContract(slot)
|
||||
return bindingSlot ? [bindingSlot] : []
|
||||
}) ?? []
|
||||
const dslEnvVarMetadataSlots = method === 'importDsl' && dslContent
|
||||
? dslEnvVarSlots(dslContent).flatMap((slot) => {
|
||||
const key = slot.key.trim()
|
||||
if (!key)
|
||||
return []
|
||||
|
||||
return [{
|
||||
key,
|
||||
...(slot.description ? { description: slot.description } : {}),
|
||||
...(slot.defaultValue !== undefined ? { defaultValue: slot.defaultValue, hasDefaultValue: true } : {}),
|
||||
...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}),
|
||||
}]
|
||||
})
|
||||
: []
|
||||
|
||||
if (dslEnvVarMetadataSlots.length === 0)
|
||||
return deploymentOptionEnvVarSlots
|
||||
|
||||
const metadataByKey = new Map(
|
||||
dslEnvVarMetadataSlots.map(slot => [slot.key, slot] as const),
|
||||
)
|
||||
|
||||
return deploymentOptionEnvVarSlots.map((slot) => {
|
||||
const metadata = metadataByKey.get(slot.key)
|
||||
if (!metadata)
|
||||
return slot
|
||||
|
||||
const nextSlot = { ...slot }
|
||||
|
||||
if (!nextSlot.description && metadata.description)
|
||||
nextSlot.description = metadata.description
|
||||
if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) {
|
||||
nextSlot.defaultValue = metadata.defaultValue
|
||||
nextSlot.hasDefaultValue = true
|
||||
}
|
||||
if (nextSlot.valueType === 'string' && metadata.valueType)
|
||||
nextSlot.valueType = metadata.valueType
|
||||
|
||||
return nextSlot
|
||||
})
|
||||
})
|
||||
|
||||
export const requiredEnvVarsReadyAtom = atom((get) => {
|
||||
const envVarValues = get(envVarValuesAtom)
|
||||
|
||||
return get(deploymentTargetEnvVarSlotsAtom).every(slot =>
|
||||
envVarSelectionReady(slot, envVarValues[slot.key]),
|
||||
)
|
||||
})
|
||||
|
||||
export const canDeployAtom = atom((get) => {
|
||||
const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom)
|
||||
const selectedEnvironment = effectiveSelectedEnvironmentId
|
||||
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
|
||||
: undefined
|
||||
|
||||
return Boolean(
|
||||
selectedEnvironment?.id
|
||||
&& get(deployableEnvironmentsReadyAtom)
|
||||
&& get(deploymentOptionsReadyAtom)
|
||||
&& get(requiredBindingsReadyAtom)
|
||||
&& get(requiredEnvVarsReadyAtom)
|
||||
&& get(submittedReleaseReadyAtom),
|
||||
)
|
||||
})
|
||||
|
||||
export const canSkipDeploymentAtom = atom((get) => {
|
||||
return get(submittedReleaseReadyAtom) && get(deploymentOptionsReadyAtom)
|
||||
})
|
||||
|
||||
export const selectBindingAtom = atom(null, (get, set, slot: string, value: string) => {
|
||||
set(manualBindingSelectionsAtom, {
|
||||
...get(manualBindingSelectionsAtom),
|
||||
[slot]: value,
|
||||
})
|
||||
})
|
||||
|
||||
export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => {
|
||||
set(envVarValuesAtom, {
|
||||
...get(envVarValuesAtom),
|
||||
[key]: value,
|
||||
})
|
||||
})
|
||||
5
web/features/deployments/create-guide/state/types.ts
Normal file
5
web/features/deployments/create-guide/state/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { App, AppModeEnum } from '@/types/app'
|
||||
|
||||
export type GuideMethod = 'bindApp' | 'importDsl'
|
||||
export type GuideStep = 'source' | 'release' | 'target'
|
||||
export type WorkflowSourceApp = App & { mode: Extract<AppModeEnum, 'workflow'> }
|
||||
101
web/features/deployments/create-guide/state/utils.ts
Normal file
101
web/features/deployments/create-guide/state/utils.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import type { EnvVarInput, Pagination } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { GuideMethod } from './types'
|
||||
import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings'
|
||||
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
|
||||
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
|
||||
|
||||
export const DEPLOYMENT_PAGE_SIZE = 100
|
||||
export const SOURCE_APPS_PAGE_SIZE = 100
|
||||
|
||||
export function getNextPageParamFromPagination(pagination?: Pagination) {
|
||||
const currentPage = pagination?.currentPage ?? 1
|
||||
const totalPages = pagination?.totalPages ?? 1
|
||||
|
||||
return currentPage < totalPages ? currentPage + 1 : undefined
|
||||
}
|
||||
|
||||
export function deploymentGuideMethod(method: GuideMethod): GuideMethod {
|
||||
return method === 'importDsl' && !isDeploymentDslImportEnabled
|
||||
? 'bindApp'
|
||||
: method
|
||||
}
|
||||
|
||||
const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
|
||||
const RANDOM_SUFFIX_LENGTH = 4
|
||||
const RANDOM_SUFFIX_FALLBACK_LENGTH = 6
|
||||
const RANDOM_SUFFIX_MAX_ATTEMPTS = 16
|
||||
|
||||
function randomLetterCombination(length: number) {
|
||||
const randomValues = new Uint8Array(length)
|
||||
|
||||
if (globalThis.crypto) {
|
||||
globalThis.crypto.getRandomValues(randomValues)
|
||||
}
|
||||
else {
|
||||
randomValues.forEach((_, index) => {
|
||||
randomValues[index] = Math.floor(Math.random() * 256)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('')
|
||||
}
|
||||
|
||||
export function availableInstanceName(sourceName: string, existingNameSet: Set<string>) {
|
||||
if (!existingNameSet.has(sourceName))
|
||||
return sourceName
|
||||
|
||||
for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) {
|
||||
const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}`
|
||||
if (!existingNameSet.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}`
|
||||
}
|
||||
|
||||
function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
|
||||
return selection?.valueSource
|
||||
?? (slot.hasDefaultValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
|
||||
: slot.hasLastValue
|
||||
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
|
||||
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
|
||||
}
|
||||
|
||||
export function envVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
|
||||
const valueSource = envVarValueSource(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 function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] {
|
||||
const valueSource = envVarValueSource(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,
|
||||
}]
|
||||
}
|
||||
122
web/features/deployments/create-guide/state/workflow.ts
Normal file
122
web/features/deployments/create-guide/state/workflow.ts
Normal file
@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkflowSourceApp } from './types'
|
||||
import { atom } from 'jotai'
|
||||
import {
|
||||
autoFilledInstanceNameAtom,
|
||||
autoFilledReleaseNameAtom,
|
||||
dslFileAtom,
|
||||
dslFileReadVersionAtom,
|
||||
effectiveMethodAtom,
|
||||
envVarValuesAtom,
|
||||
instanceNameAtom,
|
||||
manualBindingSelectionsAtom,
|
||||
methodAtom,
|
||||
releaseNameAtom,
|
||||
selectedAppAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
sourceSearchTextAtom,
|
||||
stepAtom,
|
||||
submissionUnsupportedDslNodesAtom,
|
||||
} from './primitives'
|
||||
import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom } from './queries'
|
||||
import { dslDefaultAppNameAtom, effectiveSelectedAppAtom, importDslReadyAtom } from './source'
|
||||
import { availableInstanceName, deploymentGuideMethod } from './utils'
|
||||
|
||||
export const sourceCanGoNextAtom = atom((get) => {
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
const importDslReady = method === 'importDsl' && get(importDslReadyAtom)
|
||||
const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id)
|
||||
|
||||
return (importDslReady || bindAppReady) && get(deploymentOptionsContentCheckedAtom)
|
||||
})
|
||||
|
||||
export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => {
|
||||
if (get(sourceSearchTextAtom) === value)
|
||||
return
|
||||
|
||||
set(sourceSearchTextAtom, value)
|
||||
set(selectedAppAtom, undefined)
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
})
|
||||
|
||||
export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp) => {
|
||||
set(selectedAppAtom, app)
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
})
|
||||
|
||||
export const continueFromSourceAtom = atom(null, (get, set, {
|
||||
defaultDslAppName,
|
||||
defaultReleaseName,
|
||||
}: {
|
||||
defaultDslAppName: string
|
||||
defaultReleaseName: string
|
||||
}) => {
|
||||
if (!get(sourceCanGoNextAtom))
|
||||
return
|
||||
|
||||
const method = get(effectiveMethodAtom)
|
||||
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
|
||||
if (method === 'bindApp' && effectiveSelectedApp)
|
||||
set(selectSourceAppAtom, effectiveSelectedApp)
|
||||
|
||||
const sourceName = method === 'importDsl'
|
||||
? get(dslDefaultAppNameAtom) || defaultDslAppName
|
||||
: effectiveSelectedApp?.name
|
||||
const nextInstanceName = sourceName?.trim()
|
||||
|
||||
if (nextInstanceName) {
|
||||
const currentInstanceName = get(instanceNameAtom).trim()
|
||||
const autoFilledInstanceName = get(autoFilledInstanceNameAtom)
|
||||
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
|
||||
const existingNameSet = new Set(
|
||||
existingInstanceNamesQuery.data?.pages.flatMap(page =>
|
||||
page.appInstances.flatMap((appInstance) => {
|
||||
const name = appInstance.displayName.trim()
|
||||
|
||||
return name ? [name] : []
|
||||
}),
|
||||
) ?? [],
|
||||
)
|
||||
|
||||
if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) {
|
||||
const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet)
|
||||
set(instanceNameAtom, nextAvailableInstanceName)
|
||||
set(autoFilledInstanceNameAtom, nextAvailableInstanceName)
|
||||
}
|
||||
}
|
||||
|
||||
const currentReleaseName = get(releaseNameAtom).trim()
|
||||
const autoFilledReleaseName = get(autoFilledReleaseNameAtom)
|
||||
if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) {
|
||||
set(releaseNameAtom, defaultReleaseName)
|
||||
set(autoFilledReleaseNameAtom, defaultReleaseName)
|
||||
}
|
||||
set(stepAtom, 'release')
|
||||
})
|
||||
|
||||
export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => {
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
|
||||
set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1)
|
||||
set(dslFileAtom, dslFile)
|
||||
})
|
||||
|
||||
export const selectMethodAtom = atom(null, (_get, set, method: Parameters<typeof deploymentGuideMethod>[0]) => {
|
||||
set(methodAtom, deploymentGuideMethod(method))
|
||||
set(selectedEnvironmentIdAtom, '')
|
||||
set(manualBindingSelectionsAtom, {})
|
||||
set(envVarValuesAtom, {})
|
||||
set(submissionUnsupportedDslNodesAtom, [])
|
||||
set(stepAtom, 'source')
|
||||
})
|
||||
@ -16,9 +16,7 @@ const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
sourceAppsQuery,
|
||||
useInfiniteScroll: vi.fn(() => ({
|
||||
rootEl: null,
|
||||
rootRef: vi.fn(),
|
||||
sentinelEl: null,
|
||||
sentinelRef: vi.fn(),
|
||||
})),
|
||||
}
|
||||
@ -28,29 +26,51 @@ vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({
|
||||
useInfiniteScroll: mocks.useInfiniteScroll,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/deployments/create-guide/state', async () => {
|
||||
vi.mock('@/features/deployments/create-guide/state/primitives', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp')
|
||||
|
||||
return {
|
||||
dslFileAtom: atom<File | undefined>(undefined),
|
||||
effectiveMethodAtom: atom(get => get(methodAtom)),
|
||||
methodAtom,
|
||||
sourceSearchTextAtom: atom(''),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/deployments/create-guide/state/source', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
|
||||
return {
|
||||
dslReadErrorAtom: atom(false),
|
||||
dslUnsupportedModeAtom: atom(false),
|
||||
effectiveSelectedAppAtom: atom(undefined),
|
||||
isReadingDslAtom: atom(false),
|
||||
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/deployments/create-guide/state/workflow', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
const { methodAtom } = await import('@/features/deployments/create-guide/state/primitives')
|
||||
const emptyActionAtom = atom(null, () => undefined)
|
||||
|
||||
return {
|
||||
continueFromSourceAtom: emptyActionAtom,
|
||||
dslFileAtom: atom<File | undefined>(undefined),
|
||||
dslReadErrorAtom: atom(false),
|
||||
dslUnsupportedModeAtom: atom(false),
|
||||
effectiveMethodAtom: atom(get => get(methodAtom)),
|
||||
effectiveSelectedAppAtom: atom(undefined),
|
||||
isReadingDslAtom: atom(false),
|
||||
methodAtom,
|
||||
selectDslFileAtom: emptyActionAtom,
|
||||
selectMethodAtom: atom(null, (_get, set, value: 'bindApp' | 'importDsl') => {
|
||||
set(methodAtom, value)
|
||||
}),
|
||||
selectSourceAppAtom: emptyActionAtom,
|
||||
setSourceSearchTextAtom: emptyActionAtom,
|
||||
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
|
||||
sourceCanGoNextAtom: atom(false),
|
||||
sourceSearchTextAtom: atom(''),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/deployments/create-guide/state/queries', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
|
||||
return {
|
||||
unsupportedDslNodesAtom: atom([]),
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { GuideStep } from '@/features/deployments/create-guide/state'
|
||||
import type { GuideStep } from '@/features/deployments/create-guide/state/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
|
||||
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
|
||||
|
||||
const GUIDE_PROGRESS_STEPS: GuideStep[] = ['source', 'release', 'target']
|
||||
|
||||
|
||||
@ -5,22 +5,24 @@ import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
continueFromReleaseAtom,
|
||||
dslDefaultAppNameAtom,
|
||||
effectiveMethodAtom,
|
||||
hasInstanceNameConflictAtom,
|
||||
instanceDescriptionAtom,
|
||||
instanceNameAtom,
|
||||
releaseCanGoNextAtom,
|
||||
releaseDescriptionAtom,
|
||||
releaseNameAtom,
|
||||
selectedAppAtom,
|
||||
stepAtom,
|
||||
} from '@/features/deployments/create-guide/state/primitives'
|
||||
import {
|
||||
continueFromReleaseAtom,
|
||||
hasInstanceNameConflictAtom,
|
||||
releaseCanGoNextAtom,
|
||||
setInstanceDescriptionAtom,
|
||||
setInstanceNameAtom,
|
||||
setReleaseDescriptionAtom,
|
||||
setReleaseNameAtom,
|
||||
stepAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
} from '@/features/deployments/create-guide/state/release'
|
||||
import { dslDefaultAppNameAtom } from '@/features/deployments/create-guide/state/source'
|
||||
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'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { stepAtom } from '@/features/deployments/create-guide/state'
|
||||
import { stepAtom } from '@/features/deployments/create-guide/state/primitives'
|
||||
import { GuideCard, GuideFrame } from './layout'
|
||||
import {
|
||||
ReleaseActionButtons,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state'
|
||||
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
@ -11,26 +11,30 @@ 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,
|
||||
effectiveMethodAtom,
|
||||
sourceSearchTextAtom,
|
||||
} from '@/features/deployments/create-guide/state/primitives'
|
||||
import { unsupportedDslNodesAtom } from '@/features/deployments/create-guide/state/queries'
|
||||
import {
|
||||
dslReadErrorAtom,
|
||||
dslUnsupportedModeAtom,
|
||||
effectiveMethodAtom,
|
||||
effectiveSelectedAppAtom,
|
||||
isReadingDslAtom,
|
||||
sourceAppsQueryAtom,
|
||||
} from '@/features/deployments/create-guide/state/source'
|
||||
import {
|
||||
continueFromSourceAtom,
|
||||
selectDslFileAtom,
|
||||
selectMethodAtom,
|
||||
selectSourceAppAtom,
|
||||
setSourceSearchTextAtom,
|
||||
sourceAppsQueryAtom,
|
||||
sourceCanGoNextAtom,
|
||||
sourceSearchTextAtom,
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
} from '@/features/deployments/create-guide/state/workflow'
|
||||
import { DeploymentStateMessage } from '@/features/deployments/shared/components/empty-state'
|
||||
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
|
||||
import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert'
|
||||
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
|
||||
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
|
||||
import { StepShell } from './layout'
|
||||
|
||||
@ -10,34 +10,40 @@ 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'
|
||||
envVarValuesAtom,
|
||||
isCreatingReleaseOnlyAtom,
|
||||
isSubmittingDeploymentGuideAtom,
|
||||
selectedEnvironmentIdAtom,
|
||||
stepAtom,
|
||||
} from '@/features/deployments/create-guide/state/primitives'
|
||||
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'
|
||||
deployableEnvironmentsQueryAtom,
|
||||
deploymentOptionsQueryAtom,
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state/queries'
|
||||
import {
|
||||
createDeploymentGuideSubmissionAtom,
|
||||
CreateDeploymentGuideSubmissionBlockedError,
|
||||
} from '@/features/deployments/create-guide/state/submission'
|
||||
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'
|
||||
} from '@/features/deployments/create-guide/state/target'
|
||||
import {
|
||||
EnvVarBindingsPanel,
|
||||
} from '@/features/deployments/shared/components/env-var-bindings'
|
||||
import {
|
||||
RuntimeCredentialBindingsPanel,
|
||||
} from '@/features/deployments/shared/components/runtime-credential-bindings'
|
||||
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
|
||||
import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert'
|
||||
import { deploymentErrorMessage } from '@/features/deployments/shared/domain/error'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { StepShell } from './layout'
|
||||
|
||||
18
web/features/deployments/create-release/README.md
Normal file
18
web/features/deployments/create-release/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Deployment Create Release
|
||||
|
||||
Release creation dialog for selecting a source app or DSL file and creating a new release for an existing deployment app instance.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| -------- | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the DSL release source. |
|
||||
| `app/components/base/app-icon` | Renders source app icons consistently in the source app picker. |
|
||||
| `app/components/base/skeleton` | Reuses skeleton primitives for source app picker loading rows. |
|
||||
| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. |
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Getter } from 'jotai'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClient, skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -33,6 +33,10 @@ const mockQueryResults = vi.hoisted(() => ({
|
||||
current: new Map<string, QueryResult>(),
|
||||
}))
|
||||
|
||||
const mockQueryOptions = vi.hoisted(() => ({
|
||||
current: new Map<string, QueryOptions | InfiniteQueryOptions>(),
|
||||
}))
|
||||
|
||||
const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({
|
||||
current: {
|
||||
isPending: false,
|
||||
@ -57,6 +61,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
|
||||
? undefined
|
||||
: mockQueryResults.current.get(queryName)
|
||||
|
||||
mockQueryOptions.current.set(queryName, options)
|
||||
|
||||
return {
|
||||
...options,
|
||||
data: undefined,
|
||||
@ -75,6 +81,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
|
||||
? undefined
|
||||
: mockQueryResults.current.get(queryName)
|
||||
|
||||
mockQueryOptions.current.set(queryName, options)
|
||||
|
||||
return {
|
||||
...options,
|
||||
data: undefined,
|
||||
@ -180,6 +188,7 @@ describe('create release state with DSL import enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryResults.current.clear()
|
||||
mockQueryOptions.current.clear()
|
||||
mockCreateReleaseMutation.current = {
|
||||
isPending: false,
|
||||
mutateAsync: vi.fn(),
|
||||
@ -207,6 +216,22 @@ describe('create release state with DSL import enabled', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should skip DSL release precheck input until DSL content is ready', async () => {
|
||||
const { state, store, unsubscribe } = await mountedStore()
|
||||
|
||||
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
|
||||
store.set(state.openCreateReleaseDialogAtom)
|
||||
store.set(state.selectCreateReleaseSourceModeAtom, 'dsl')
|
||||
store.get(state.isCheckingCreateReleaseContentAtom)
|
||||
|
||||
expect(mockQueryOptions.current.get('precheckRelease')).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should submit a DSL release with encoded workflow content', async () => {
|
||||
const { state, store, unsubscribe } = await mountedStore()
|
||||
const response = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Getter } from 'jotai'
|
||||
import type { CreateReleaseFormValues } from '../index'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClient, skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -31,6 +31,10 @@ const mockQueryResults = vi.hoisted(() => ({
|
||||
current: new Map<string, QueryResult>(),
|
||||
}))
|
||||
|
||||
const mockQueryOptions = vi.hoisted(() => ({
|
||||
current: new Map<string, QueryOptions>(),
|
||||
}))
|
||||
|
||||
const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({
|
||||
current: {
|
||||
isPending: false,
|
||||
@ -51,6 +55,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
|
||||
? undefined
|
||||
: mockQueryResults.current.get(queryName)
|
||||
|
||||
mockQueryOptions.current.set(queryName, options)
|
||||
|
||||
return {
|
||||
...options,
|
||||
data: undefined,
|
||||
@ -226,6 +232,7 @@ describe('create release state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryResults.current.clear()
|
||||
mockQueryOptions.current.clear()
|
||||
mockCreateReleaseMutation.current = {
|
||||
isPending: false,
|
||||
mutateAsync: vi.fn(),
|
||||
@ -334,6 +341,37 @@ describe('create release state', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should reset source app search text when opening or closing the dialog', async () => {
|
||||
const { state, store, unsubscribe } = await mountedStore()
|
||||
|
||||
store.set(state.createReleaseSourceAppSearchTextAtom, 'customer')
|
||||
store.set(state.openCreateReleaseDialogAtom)
|
||||
|
||||
expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('')
|
||||
|
||||
store.set(state.createReleaseSourceAppSearchTextAtom, 'support')
|
||||
store.set(state.closeCreateReleaseDialogAtom)
|
||||
|
||||
expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('')
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should skip release content precheck input until source content is ready', async () => {
|
||||
const { state, store, unsubscribe } = await mountedStore()
|
||||
|
||||
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
|
||||
store.set(state.openCreateReleaseDialogAtom)
|
||||
store.get(state.isCheckingCreateReleaseContentAtom)
|
||||
|
||||
expect(mockQueryOptions.current.get('precheckRelease')).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should capture DSL file read failures and clear them when opening or closing the dialog', async () => {
|
||||
const { state, store, unsubscribe } = await mountedStore()
|
||||
const file = new File(['broken'], 'broken.yml', { type: 'text/yaml' })
|
||||
|
||||
@ -361,7 +361,7 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => {
|
||||
const canCheck = canCheckReleaseContent(get)
|
||||
|
||||
return consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
|
||||
input: appInstanceId
|
||||
input: canCheck && appInstanceId
|
||||
? releaseSourceMode === 'dsl'
|
||||
? {
|
||||
body: {
|
||||
@ -424,14 +424,20 @@ const resetCreateReleaseDslFileAtom = atom(null, (get, set) => {
|
||||
set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1)
|
||||
})
|
||||
|
||||
const resetCreateReleaseSourceAppSearchAtom = atom(null, (_get, set) => {
|
||||
set(createReleaseSourceAppSearchTextAtom, '')
|
||||
})
|
||||
|
||||
export const openCreateReleaseDialogAtom = atom(null, (_get, set) => {
|
||||
set(resetCreateReleaseDslFileAtom)
|
||||
set(resetCreateReleaseSourceAppSearchAtom)
|
||||
set(createReleaseDialogOpenAtom, true)
|
||||
})
|
||||
|
||||
export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => {
|
||||
set(createReleaseDialogOpenAtom, false)
|
||||
set(resetCreateReleaseDslFileAtom)
|
||||
set(resetCreateReleaseSourceAppSearchAtom)
|
||||
})
|
||||
|
||||
export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => {
|
||||
|
||||
@ -25,9 +25,7 @@ const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
sourceAppsQuery,
|
||||
useInfiniteScroll: vi.fn(() => ({
|
||||
rootEl: null,
|
||||
rootRef: vi.fn(),
|
||||
sentinelEl: null,
|
||||
sentinelRef: vi.fn(),
|
||||
})),
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert'
|
||||
import { UnsupportedDslNodesAlert } from '../../shared/components/unsupported-dsl-nodes-alert'
|
||||
import {
|
||||
createReleaseContentCheckFailedAtom,
|
||||
createReleaseMatchedReleaseAtom,
|
||||
|
||||
@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { TitleTooltip } from '../../shared/components/title-tooltip'
|
||||
import {
|
||||
createReleaseSourceAppSearchTextAtom,
|
||||
createReleaseSourceAppsQueryAtom,
|
||||
|
||||
15
web/features/deployments/deploy-drawer/README.md
Normal file
15
web/features/deployments/deploy-drawer/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Deployment Deploy Drawer
|
||||
|
||||
Drawer workflow for choosing a release, selecting an environment, binding runtime credentials, and starting a deployment.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| -------- | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------------------------ | ------------------------------------------------------------ |
|
||||
| `app/components/base/skeleton` | Reuses skeleton primitives for drawer form loading sections. |
|
||||
@ -13,9 +13,21 @@ import {
|
||||
PluginCategory,
|
||||
RuntimeInstanceStatus,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type QueryOptions = {
|
||||
data?: unknown
|
||||
enabled?: boolean
|
||||
input?: unknown
|
||||
isError?: boolean
|
||||
isFetching?: boolean
|
||||
isLoading?: boolean
|
||||
queryKey?: readonly unknown[]
|
||||
retry?: boolean
|
||||
}
|
||||
|
||||
type QueryResult = {
|
||||
data?: {
|
||||
options: DeploymentOptions
|
||||
@ -62,9 +74,22 @@ const mockRollbackMutation = vi.hoisted<{ current: MutationResult }>(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('jotai-tanstack-query', () => ({
|
||||
atomWithQuery: (createOptions: (get: Getter) => unknown) => atom((get) => {
|
||||
createOptions(get)
|
||||
return mockDeploymentOptionsQuery.current
|
||||
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
|
||||
const options = createOptions(get)
|
||||
if (options.queryKey?.[0] === 'computeDeploymentOptions') {
|
||||
return {
|
||||
...options,
|
||||
...mockDeploymentOptionsQuery.current,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}
|
||||
}),
|
||||
atomWithMutation: (createOptions: () => MutationOptions) => atom(() => {
|
||||
const options = createOptions()
|
||||
@ -78,6 +103,13 @@ vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
releaseService: {
|
||||
computeReleaseDeploymentView: {
|
||||
queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({
|
||||
enabled,
|
||||
input,
|
||||
queryKey: ['computeReleaseDeploymentView', input],
|
||||
}),
|
||||
},
|
||||
computeDeploymentOptions: {
|
||||
queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({
|
||||
enabled,
|
||||
@ -256,6 +288,23 @@ describe('deploy drawer state', () => {
|
||||
expect(store.get(state.deployDrawerReleaseIdAtom)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should disable release deployment view query with skipToken until form app instance exists', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
|
||||
expect(store.get(state.releaseDeploymentViewQueryAtom)).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
|
||||
store.set(state.deployFormAppInstanceIdAtom, 'app-instance-1')
|
||||
|
||||
expect(store.get(state.releaseDeploymentViewQueryAtom)).toMatchObject({
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('should derive default environment and release selections from config', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
|
||||
@ -11,21 +11,21 @@ import type {
|
||||
EnvVarBindingSlot,
|
||||
EnvVarValues,
|
||||
EnvVarValueSelection,
|
||||
} from '../../components/env-var-bindings'
|
||||
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
|
||||
} from '../../shared/components/env-var-bindings'
|
||||
import type { RuntimeCredentialBindingSelections } from '../../shared/components/runtime-credential-bindings-utils'
|
||||
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
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 { envVarBindingSlotFromContract } from '../../shared/components/env-var-bindings-utils'
|
||||
import {
|
||||
hasMissingRequiredRuntimeCredentialBinding,
|
||||
runtimeCredentialSlotKey,
|
||||
selectedDeploymentRuntimeCredentials,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '../../components/runtime-credential-bindings-utils'
|
||||
} from '../../shared/components/runtime-credential-bindings-utils'
|
||||
import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
|
||||
import { releaseDeploymentAction } from '../../shared/domain/release-action'
|
||||
|
||||
@ -39,7 +39,7 @@ 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 deployFormAppInstanceIdAtom = atom('')
|
||||
export const deployFormAppInstanceIdAtom = atom<string | undefined>(undefined)
|
||||
|
||||
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
|
||||
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
|
||||
@ -72,9 +72,11 @@ export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => {
|
||||
const appInstanceId = get(deployFormAppInstanceIdAtom)
|
||||
|
||||
return consoleQuery.enterprise.releaseService.computeReleaseDeploymentView.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
input: appInstanceId
|
||||
? {
|
||||
params: { appInstanceId },
|
||||
}
|
||||
: skipToken,
|
||||
enabled: Boolean(appInstanceId),
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { CredentialSlot, Environment } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
|
||||
import type { RuntimeCredentialBindingSelections } from '../../shared/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 { DeploymentStateMessage } from '../../components/empty-state'
|
||||
import { RuntimeCredentialBindingsPanel } from '../../components/runtime-credential-bindings'
|
||||
import { DeploymentStateMessage } from '../../shared/components/empty-state'
|
||||
import { RuntimeCredentialBindingsPanel } from '../../shared/components/runtime-credential-bindings'
|
||||
import { formatDate, releaseCommit } from '../../shared/domain/release'
|
||||
import {
|
||||
deployDisplayedReleaseAtom,
|
||||
|
||||
@ -9,7 +9,7 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EnvVarBindingsPanel } from '../../components/env-var-bindings'
|
||||
import { EnvVarBindingsPanel } from '../../shared/components/env-var-bindings'
|
||||
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
|
||||
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewQueryAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
|
||||
import {
|
||||
|
||||
@ -6,7 +6,7 @@ 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 { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { TitleTooltip } from '../../shared/components/title-tooltip'
|
||||
import { ModeBadge } from './status-badge'
|
||||
|
||||
export function Field({ label, hint, children }: {
|
||||
|
||||
11
web/features/deployments/deployment-actions/README.md
Normal file
11
web/features/deployments/deployment-actions/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Deployment Actions
|
||||
|
||||
Action menu and edit/delete dialogs for deployment app instances.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
None.
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
@ -1,42 +1,13 @@
|
||||
import type { Getter } from 'jotai/vanilla'
|
||||
import type { DeploymentActionAppInstance } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { DeleteDeploymentDialog } from '../delete-dialog'
|
||||
import {
|
||||
deleteDeploymentDialogOpenAtom,
|
||||
deploymentActionAppInstanceIdAtom,
|
||||
deploymentActionAppInstanceAtom,
|
||||
} from '../state'
|
||||
|
||||
type QueryOptions = {
|
||||
input?: unknown
|
||||
queryKey?: readonly unknown[]
|
||||
}
|
||||
|
||||
type QueryResult = {
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
const mockQueryResults = vi.hoisted(() => ({
|
||||
current: new Map<string, QueryResult>(),
|
||||
}))
|
||||
|
||||
const useQueryMock = vi.hoisted(() =>
|
||||
vi.fn((options: QueryOptions) => {
|
||||
const queryName = String(options.queryKey?.[0] ?? 'unknown')
|
||||
const queryResult = mockQueryResults.current.get(queryName)
|
||||
|
||||
return {
|
||||
...options,
|
||||
data: queryResult?.data,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isSuccess: Boolean(queryResult?.data),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const deleteMutationMock = vi.hoisted(() => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
@ -58,16 +29,6 @@ const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('jotai-tanstack-query', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
|
||||
return {
|
||||
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
|
||||
return useQueryMock(createOptions(get))
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: useMutationMock,
|
||||
}))
|
||||
@ -84,12 +45,6 @@ vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
appInstanceService: {
|
||||
getAppInstance: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getAppInstance', options.input],
|
||||
}),
|
||||
},
|
||||
deleteAppInstance: {
|
||||
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
|
||||
},
|
||||
@ -98,26 +53,26 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function setAppInstance() {
|
||||
mockQueryResults.current.set('getAppInstance', {
|
||||
data: {
|
||||
appInstance: {
|
||||
id: 'app-instance-1',
|
||||
displayName: 'Deployment 1',
|
||||
},
|
||||
},
|
||||
})
|
||||
function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance {
|
||||
return {
|
||||
id: 'app-instance-1',
|
||||
displayName: 'Deployment 1',
|
||||
description: 'Initial description',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderDialog({
|
||||
appInstance = createAppInstance(),
|
||||
open = true,
|
||||
}: {
|
||||
appInstance?: DeploymentActionAppInstance
|
||||
open?: boolean
|
||||
} = {}) {
|
||||
render(
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
|
||||
[deploymentActionAppInstanceAtom, appInstance],
|
||||
[deleteDeploymentDialogOpenAtom, open],
|
||||
]}
|
||||
name="DeleteDeploymentDialogTest"
|
||||
@ -130,16 +85,13 @@ function renderDialog({
|
||||
describe('DeleteDeploymentDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryResults.current.clear()
|
||||
deleteMutationMock.isPending = false
|
||||
setAppInstance()
|
||||
})
|
||||
|
||||
describe('Delete action', () => {
|
||||
it('should not mount the query or delete mutation before the dialog is opened', () => {
|
||||
it('should not mount the delete mutation before the dialog is opened', () => {
|
||||
renderDialog({ open: false })
|
||||
|
||||
expect(useQueryMock).not.toHaveBeenCalled()
|
||||
expect(useMutationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -1,44 +1,13 @@
|
||||
import type { Getter } from 'jotai/vanilla'
|
||||
import type { DeploymentActionAppInstance } from '../types'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { EditDeploymentDialog } from '../edit-dialog'
|
||||
import {
|
||||
deploymentActionAppInstanceIdAtom,
|
||||
deploymentActionAppInstanceAtom,
|
||||
editDeploymentDialogOpenAtom,
|
||||
} from '../state'
|
||||
|
||||
type QueryOptions = {
|
||||
input?: unknown
|
||||
queryKey?: readonly unknown[]
|
||||
}
|
||||
|
||||
type QueryResult = {
|
||||
data?: unknown
|
||||
isError?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const mockQueryResults = vi.hoisted(() => ({
|
||||
current: new Map<string, QueryResult>(),
|
||||
}))
|
||||
|
||||
const useQueryMock = vi.hoisted(() =>
|
||||
vi.fn((options: QueryOptions) => {
|
||||
const queryName = String(options.queryKey?.[0] ?? 'unknown')
|
||||
const queryResult = mockQueryResults.current.get(queryName)
|
||||
|
||||
return {
|
||||
...options,
|
||||
data: queryResult?.data,
|
||||
isError: queryResult?.isError ?? false,
|
||||
isFetching: false,
|
||||
isLoading: queryResult?.isLoading ?? false,
|
||||
isSuccess: Boolean(queryResult?.data),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const updateMutationMock = vi.hoisted(() => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
@ -56,16 +25,6 @@ const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('jotai-tanstack-query', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
|
||||
return {
|
||||
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
|
||||
return useQueryMock(createOptions(get))
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: useMutationMock,
|
||||
}))
|
||||
@ -78,51 +37,34 @@ vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
appInstanceService: {
|
||||
getAppInstance: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getAppInstance', options.input],
|
||||
}),
|
||||
},
|
||||
updateAppInstance: {
|
||||
mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }),
|
||||
},
|
||||
deleteAppInstance: {
|
||||
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function setAppInstance(overrides: Record<string, unknown> = {}) {
|
||||
mockQueryResults.current.set('getAppInstance', {
|
||||
data: {
|
||||
appInstance: {
|
||||
id: 'app-instance-1',
|
||||
displayName: 'Deployment 1',
|
||||
description: 'Initial description',
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setAppInstanceLoading() {
|
||||
mockQueryResults.current.set('getAppInstance', {
|
||||
isLoading: true,
|
||||
})
|
||||
function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance {
|
||||
return {
|
||||
id: 'app-instance-1',
|
||||
displayName: 'Deployment 1',
|
||||
description: 'Initial description',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderDialog({
|
||||
appInstance = createAppInstance(),
|
||||
open = true,
|
||||
}: {
|
||||
appInstance?: DeploymentActionAppInstance
|
||||
open?: boolean
|
||||
} = {}) {
|
||||
render(
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
|
||||
[deploymentActionAppInstanceAtom, appInstance],
|
||||
[editDeploymentDialogOpenAtom, open],
|
||||
]}
|
||||
name="EditDeploymentDialogTest"
|
||||
@ -135,25 +77,22 @@ function renderDialog({
|
||||
describe('EditDeploymentDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryResults.current.clear()
|
||||
updateMutationMock.isPending = false
|
||||
setAppInstance()
|
||||
})
|
||||
|
||||
describe('Form submission', () => {
|
||||
it('should not mount the query or update mutation before the dialog is opened', () => {
|
||||
it('should not mount the update mutation before the dialog is opened', () => {
|
||||
renderDialog({ open: false })
|
||||
|
||||
expect(useQueryMock).not.toHaveBeenCalled()
|
||||
expect(useMutationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create the update mutation only after the edit form is ready', () => {
|
||||
setAppInstanceLoading()
|
||||
|
||||
it('should render form values from the passed app instance', () => {
|
||||
renderDialog()
|
||||
|
||||
expect(useMutationMock).not.toHaveBeenCalled()
|
||||
const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' })
|
||||
expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' })).toHaveValue('Deployment 1')
|
||||
expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' })).toHaveValue('Initial description')
|
||||
})
|
||||
|
||||
it('should submit trimmed deployment metadata through the component mutation', async () => {
|
||||
@ -1,66 +1,51 @@
|
||||
import type { DeploymentActionAppInstance } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DeploymentActionsMenu } from './index'
|
||||
|
||||
type QueryOptions = {
|
||||
input?: unknown
|
||||
queryKey?: readonly unknown[]
|
||||
}
|
||||
import { DeploymentActionsMenu } from '../index'
|
||||
|
||||
const editDialogMock = vi.hoisted(() => vi.fn())
|
||||
const deleteDialogMock = vi.hoisted(() => vi.fn())
|
||||
const prefetchQueryMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
prefetchQuery: prefetchQueryMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
appInstanceService: {
|
||||
getAppInstance: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getAppInstance', options.input],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./edit-dialog', async () => {
|
||||
vi.mock('../edit-dialog', async () => {
|
||||
const { useAtomValue } = await import('jotai')
|
||||
const { editDeploymentDialogOpenAtom } = await import('./state')
|
||||
const { deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom } = await import('../state')
|
||||
|
||||
return {
|
||||
EditDeploymentDialog: () => {
|
||||
const open = useAtomValue(editDeploymentDialogOpenAtom)
|
||||
editDialogMock({ open })
|
||||
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
|
||||
editDialogMock({ appInstanceId: appInstance.id, open })
|
||||
|
||||
return <div data-testid="edit-dialog" data-open={String(open)} />
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./delete-dialog', async () => {
|
||||
vi.mock('../delete-dialog', async () => {
|
||||
const { useAtomValue } = await import('jotai')
|
||||
const { deleteDeploymentDialogOpenAtom } = await import('./state')
|
||||
const { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } = await import('../state')
|
||||
|
||||
return {
|
||||
DeleteDeploymentDialog: () => {
|
||||
const open = useAtomValue(deleteDeploymentDialogOpenAtom)
|
||||
deleteDialogMock({ open })
|
||||
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
|
||||
deleteDialogMock({ appInstanceId: appInstance.id, open })
|
||||
|
||||
return <div data-testid="delete-dialog" data-open={String(open)} />
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance {
|
||||
return {
|
||||
id: 'app-instance-1',
|
||||
displayName: 'Deployment 1',
|
||||
description: 'Initial description',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeploymentActionsMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -69,7 +54,7 @@ describe('DeploymentActionsMenu', () => {
|
||||
it('keeps the trigger wrapper visible through uncontrolled menu state', () => {
|
||||
const { container } = render(
|
||||
<DeploymentActionsMenu
|
||||
appInstanceId="app-instance-1"
|
||||
appInstance={createAppInstance()}
|
||||
placement="bottom-end"
|
||||
className="pointer-events-none opacity-0"
|
||||
/>,
|
||||
@ -84,41 +69,12 @@ describe('DeploymentActionsMenu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prefetches the app instance when the menu opens', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<DeploymentActionsMenu
|
||||
appInstanceId="app-instance-1"
|
||||
placement="bottom-end"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(prefetchQueryMock).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
|
||||
await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' })
|
||||
|
||||
expect(prefetchQueryMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: {
|
||||
params: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
},
|
||||
},
|
||||
queryKey: ['getAppInstance', {
|
||||
params: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
},
|
||||
}],
|
||||
}))
|
||||
})
|
||||
|
||||
it('opens edit and delete dialogs from menu items', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<DeploymentActionsMenu
|
||||
appInstanceId="app-instance-1"
|
||||
appInstance={createAppInstance()}
|
||||
placement="bottom-end"
|
||||
/>,
|
||||
)
|
||||
@ -137,7 +93,7 @@ describe('DeploymentActionsMenu', () => {
|
||||
|
||||
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
|
||||
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'true')
|
||||
expect(editDialogMock).toHaveBeenLastCalledWith({ open: false })
|
||||
expect(deleteDialogMock).toHaveBeenLastCalledWith({ open: true })
|
||||
expect(editDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: false })
|
||||
expect(deleteDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: true })
|
||||
})
|
||||
})
|
||||
@ -17,24 +17,22 @@ import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
deleteDeploymentDialogOpenAtom,
|
||||
deploymentActionAppInstanceIdAtom,
|
||||
deploymentActionAppInstanceQueryAtom,
|
||||
deploymentActionAppInstanceAtom,
|
||||
} from './state'
|
||||
|
||||
function DeleteDeploymentDialogContent() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
|
||||
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
|
||||
const setOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
|
||||
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
|
||||
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
|
||||
const displayName = instanceQuery.data?.appInstance.displayName || appInstanceId
|
||||
const displayName = appInstance.displayName || appInstance.id
|
||||
|
||||
function handleDelete() {
|
||||
deleteInstance.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
appInstanceId: appInstance.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -14,11 +14,9 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
deploymentActionAppInstanceIdAtom,
|
||||
deploymentActionAppInstanceQueryAtom,
|
||||
deploymentActionAppInstanceAtom,
|
||||
editDeploymentDialogOpenAtom,
|
||||
} from './state'
|
||||
|
||||
@ -46,33 +44,15 @@ function canSubmitEditDeploymentForm(initialValues: EditDeploymentFormValues, va
|
||||
)
|
||||
}
|
||||
|
||||
function EditDeploymentFormSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SkeletonRectangle className="my-0 h-3 w-28 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-24 w-full animate-pulse rounded-lg" />
|
||||
</div>
|
||||
<SkeletonRow className="justify-end gap-2">
|
||||
<SkeletonRectangle className="my-0 h-8 w-16 animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditDeploymentForm({
|
||||
initialValues,
|
||||
}: {
|
||||
initialValues: EditDeploymentFormValues
|
||||
}) {
|
||||
function EditDeploymentForm() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const nameLabel = t('settings.name')
|
||||
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
|
||||
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
|
||||
const appInstanceId = appInstance.id
|
||||
const initialValues = {
|
||||
name: appInstance.displayName,
|
||||
description: appInstance.description,
|
||||
}
|
||||
const setOpen = useSetAtom(editDeploymentDialogOpenAtom)
|
||||
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
|
||||
|
||||
@ -162,33 +142,19 @@ function EditDeploymentForm({
|
||||
|
||||
function EditDeploymentDialogContent() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
|
||||
const app = instanceQuery.data?.appInstance
|
||||
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
{!app && <DialogCloseButton />}
|
||||
<div className="border-b border-divider-subtle px-6 py-5">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('card.menu.editInfo')}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
{instanceQuery.isLoading
|
||||
? <EditDeploymentFormSkeleton />
|
||||
: instanceQuery.isError
|
||||
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
|
||||
: app
|
||||
? (
|
||||
<EditDeploymentForm
|
||||
key={`${app.id}-${app.displayName}-${app.description}`}
|
||||
initialValues={{
|
||||
name: app.displayName,
|
||||
description: app.description,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: <div className="system-sm-regular text-text-tertiary">{t('detail.notFound')}</div>}
|
||||
<EditDeploymentForm
|
||||
key={`${appInstance.id}-${appInstance.displayName}-${appInstance.description}`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { DeploymentActionAppInstance } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -9,15 +10,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DeleteDeploymentDialog } from './delete-dialog'
|
||||
import { EditDeploymentDialog } from './edit-dialog'
|
||||
import {
|
||||
deploymentActionAppInstanceIdAtom,
|
||||
deploymentActionAppInstanceQueryOptionsAtom,
|
||||
deploymentActionAppInstanceAtom,
|
||||
deploymentActionsLocalAtoms,
|
||||
openDeleteDeploymentDialogAtom,
|
||||
openEditDeploymentDialogAtom,
|
||||
@ -30,7 +29,7 @@ const ACTION_TRIGGER_CLASS_NAME = cn(
|
||||
)
|
||||
|
||||
type DeploymentActionsMenuProps = {
|
||||
appInstanceId: string
|
||||
appInstance: DeploymentActionAppInstance
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
placement: ComponentProps<typeof DropdownMenuContent>['placement']
|
||||
@ -42,18 +41,11 @@ function DeploymentActionsMenuContent({
|
||||
triggerClassName,
|
||||
placement,
|
||||
sideOffset,
|
||||
}: Omit<DeploymentActionsMenuProps, 'appInstanceId'>) {
|
||||
}: Omit<DeploymentActionsMenuProps, 'appInstance'>) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const queryClient = useQueryClient()
|
||||
const appInstanceQueryOptions = useAtomValue(deploymentActionAppInstanceQueryOptionsAtom)
|
||||
const openEditDialog = useSetAtom(openEditDeploymentDialogAtom)
|
||||
const openDeleteDialog = useSetAtom(openDeleteDeploymentDialogAtom)
|
||||
|
||||
function handleMenuOpenChange(open: boolean) {
|
||||
if (open)
|
||||
void queryClient.prefetchQuery(appInstanceQueryOptions)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
@ -64,7 +56,7 @@ function DeploymentActionsMenuContent({
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => event.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('card.moreActions')}
|
||||
className={cn(ACTION_TRIGGER_CLASS_NAME, triggerClassName)}
|
||||
@ -94,14 +86,14 @@ function DeploymentActionsMenuContent({
|
||||
}
|
||||
|
||||
export function DeploymentActionsMenu({
|
||||
appInstanceId,
|
||||
appInstance,
|
||||
...props
|
||||
}: DeploymentActionsMenuProps) {
|
||||
return (
|
||||
<ScopeProvider
|
||||
key={appInstanceId}
|
||||
key={appInstance.id}
|
||||
atoms={[
|
||||
[deploymentActionAppInstanceIdAtom, appInstanceId],
|
||||
[deploymentActionAppInstanceAtom, appInstance],
|
||||
...deploymentActionsLocalAtoms,
|
||||
]}
|
||||
name="DeploymentActionsMenu"
|
||||
27
web/features/deployments/deployment-actions/state.ts
Normal file
27
web/features/deployments/deployment-actions/state.ts
Normal file
@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import type { DeploymentActionAppInstance } from './types'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithLazy } from 'jotai/utils'
|
||||
|
||||
export const deploymentActionAppInstanceAtom = atomWithLazy<DeploymentActionAppInstance>(() => {
|
||||
throw new Error('Missing deployment action app instance.')
|
||||
})
|
||||
|
||||
export const editDeploymentDialogOpenAtom = atom(false)
|
||||
export const deleteDeploymentDialogOpenAtom = atom(false)
|
||||
|
||||
export const openEditDeploymentDialogAtom = atom(null, (_get, set) => {
|
||||
set(deleteDeploymentDialogOpenAtom, false)
|
||||
set(editDeploymentDialogOpenAtom, true)
|
||||
})
|
||||
|
||||
export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => {
|
||||
set(editDeploymentDialogOpenAtom, false)
|
||||
set(deleteDeploymentDialogOpenAtom, true)
|
||||
})
|
||||
|
||||
export const deploymentActionsLocalAtoms = [
|
||||
editDeploymentDialogOpenAtom,
|
||||
deleteDeploymentDialogOpenAtom,
|
||||
] as const
|
||||
3
web/features/deployments/deployment-actions/types.ts
Normal file
3
web/features/deployments/deployment-actions/types.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export type DeploymentActionAppInstance = Pick<AppInstance, 'id' | 'displayName' | 'description'>
|
||||
20
web/features/deployments/detail/README.md
Normal file
20
web/features/deployments/detail/README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Deployment Detail Shell
|
||||
|
||||
Detail shell for the deployment app instance routes, including sidebar navigation, route headers, and route-level action slots.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `create-release` | Opens release creation from the detail header. |
|
||||
| `deployment-actions` | Reuses app instance action menu behavior in the detail sidebar. |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
| `route-state` | Reads route identity used by detail query and tab atoms. |
|
||||
| `detail/api-tokens` | Renders the developer API header switch for the API tokens route. |
|
||||
| `detail/instances/header-actions` | Renders the new deployment header action for the instances route. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `app/components/goto-anything/atoms` | Opens the global search command from the detail sidebar shortcut. |
|
||||
@ -1,25 +1,37 @@
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { Getter } from 'jotai'
|
||||
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
|
||||
|
||||
type QueryOptions = {
|
||||
data?: unknown
|
||||
enabled?: boolean
|
||||
input?: unknown
|
||||
queryKey?: readonly unknown[]
|
||||
refetchInterval?: (query: { state: { data?: unknown } }) => number | false
|
||||
}
|
||||
|
||||
const mockEnvironmentDeploymentsData = vi.hoisted<{
|
||||
current?: { environmentDeployments: EnvironmentDeployment[] }
|
||||
}>(() => ({}))
|
||||
|
||||
vi.mock('jotai-tanstack-query', () => ({
|
||||
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
|
||||
...createOptions(get),
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
})),
|
||||
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
|
||||
const options = createOptions(get)
|
||||
return {
|
||||
...options,
|
||||
data: options.queryKey?.[0] === 'listEnvironmentDeployments'
|
||||
? mockEnvironmentDeploymentsData.current
|
||||
: undefined,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
@ -32,12 +44,6 @@ vi.mock('@/service/client', () => ({
|
||||
queryKey: ['getAppInstance', options.input],
|
||||
}),
|
||||
},
|
||||
getAppInstanceOverview: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getAppInstanceOverview', options.input],
|
||||
}),
|
||||
},
|
||||
},
|
||||
deploymentService: {
|
||||
listEnvironmentDeployments: {
|
||||
@ -55,14 +61,36 @@ async function loadState() {
|
||||
return await import('../state')
|
||||
}
|
||||
|
||||
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
|
||||
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1', tab = 'overview') {
|
||||
store.set(setNextRouteStateAtom, {
|
||||
pathname: `/deployments/${appInstanceId}/overview`,
|
||||
pathname: `/deployments/${appInstanceId}/${tab}`,
|
||||
params: { appInstanceId },
|
||||
})
|
||||
}
|
||||
|
||||
function deploymentRow(id: string, overrides: Partial<EnvironmentDeployment> = {}) {
|
||||
return {
|
||||
environment: {
|
||||
id,
|
||||
displayName: id,
|
||||
},
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
|
||||
currentRelease: {
|
||||
id: `release-${id}`,
|
||||
},
|
||||
desiredRelease: undefined,
|
||||
currentDeployment: {
|
||||
id: `deployment-${id}`,
|
||||
},
|
||||
...overrides,
|
||||
} as EnvironmentDeployment
|
||||
}
|
||||
|
||||
describe('deployment detail state', () => {
|
||||
beforeEach(() => {
|
||||
mockEnvironmentDeploymentsData.current = undefined
|
||||
})
|
||||
|
||||
it('should disable detail queries with skipToken until a route app instance exists', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
@ -71,10 +99,6 @@ describe('deployment detail state', () => {
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
expect(store.get(state.deploymentEnvironmentDeploymentsQueryAtom)).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
@ -91,10 +115,6 @@ describe('deployment detail state', () => {
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
|
||||
const environmentDeploymentsQuery = store.get(state.deploymentEnvironmentDeploymentsQueryAtom) as unknown as QueryOptions
|
||||
expect(environmentDeploymentsQuery).toMatchObject({
|
||||
@ -103,4 +123,33 @@ describe('deployment detail state', () => {
|
||||
})
|
||||
expect(environmentDeploymentsQuery.refetchInterval).toEqual(expect.any(Function))
|
||||
})
|
||||
|
||||
it('should derive active detail tab from route pathname', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
|
||||
setDeploymentRoute(store, 'app-instance-1', 'releases')
|
||||
expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('releases')
|
||||
|
||||
setDeploymentRoute(store, 'app-instance-1', 'unknown')
|
||||
expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('overview')
|
||||
})
|
||||
|
||||
it('should derive runtime instance rows from environment deployments', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
mockEnvironmentDeploymentsData.current = {
|
||||
environmentDeployments: [
|
||||
deploymentRow('running'),
|
||||
deploymentRow('undeployed', {
|
||||
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNSPECIFIED,
|
||||
currentRelease: undefined,
|
||||
desiredRelease: undefined,
|
||||
currentDeployment: undefined,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
expect(store.get(state.deploymentRuntimeInstanceRowsAtom).map(row => row.environment.id)).toEqual(['running'])
|
||||
})
|
||||
})
|
||||
|
||||
15
web/features/deployments/detail/access/README.md
Normal file
15
web/features/deployments/detail/access/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Deployment Access
|
||||
|
||||
Access route composition for webapp channels and environment permission sections.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| --------------------------- | ------------------------------------------------------------------ |
|
||||
| `route-state` | Reads the route app instance identity for access settings queries. |
|
||||
| `detail/access/channels` | Renders access channel endpoint settings. |
|
||||
| `detail/access/permissions` | Renders environment access policy settings. |
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
@ -31,12 +31,6 @@ vi.mock('@/service/client', () => ({
|
||||
queryKey: ['getAccessSettings', options.input],
|
||||
}),
|
||||
},
|
||||
getDeveloperApiSettings: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getDeveloperApiSettings', options.input],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -62,10 +56,6 @@ describe('deployment access state', () => {
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
|
||||
setDeploymentRoute(store)
|
||||
|
||||
@ -73,9 +63,5 @@ describe('deployment access state', () => {
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
17
web/features/deployments/detail/access/channels/README.md
Normal file
17
web/features/deployments/detail/access/channels/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Deployment Access Channels
|
||||
|
||||
Access channels section for viewing webapp and developer API endpoints and toggling webapp access.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| --------------- | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
| `route-state` | Reads the route app instance identity for access-channel mutations. |
|
||||
| `detail/access` | Reads access route query data for channel settings. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------------------------ | --------------------------------------------------------------- |
|
||||
| `app/components/base/skeleton` | Reuses skeleton primitives for channel endpoint loading states. |
|
||||
@ -8,10 +8,10 @@ import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { Section } from '../../components/section'
|
||||
import { CopyPill, EndpointRow } from '../components/endpoint'
|
||||
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../shared/components/empty-state'
|
||||
import { CopyPill, EndpointRow } from '../../../shared/components/endpoint'
|
||||
import { Section } from '../../../shared/components/section'
|
||||
import { accessSettingsQueryAtom } from '../state'
|
||||
import { getUrlOrigin } from './url'
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { AccessChannelsSection } from './channels/section'
|
||||
import { AccessPermissionsSection } from './permissions/section'
|
||||
|
||||
export function AccessTab() {
|
||||
export function DeploymentAccess() {
|
||||
return (
|
||||
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
|
||||
<AccessChannelsSection />
|
||||
18
web/features/deployments/detail/access/permissions/README.md
Normal file
18
web/features/deployments/detail/access/permissions/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Deployment Access Permissions
|
||||
|
||||
Environment permission section for viewing and editing access policies on deployment environments.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
| `route-state` | Reads the route app instance identity for access-policy mutations. |
|
||||
| `detail/access` | Reads access route query data for environment access policies. |
|
||||
| `detail/access/permissions/access-subject-selector` | Selects specific members and groups for access policies. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------------------------ | --------------------------------------------------------- |
|
||||
| `app/components/base/skeleton` | Reuses skeleton primitives for permission loading states. |
|
||||
@ -0,0 +1,17 @@
|
||||
# Deployment Access Subject Selector
|
||||
|
||||
Subject selector used by access permission dialogs to search, add, and display members or groups.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
None.
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `app/components/base/loading` | Reuses the inline loading indicator while access-subject search is pending. |
|
||||
| `app/components/base/skeleton` | Reuses skeleton primitives for selected subject loading rows. |
|
||||
| `context/app-context` | Reads the current user id to label the current user in selectable subject options. |
|
||||
| `models/access-control` | Reuses access-control subject types and group models for selector values. |
|
||||
| `service/access-control/use-access-subjects` | Searches users and groups for access-policy subject selection. |
|
||||
@ -4,9 +4,9 @@ import type { EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.g
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { Section } from '../../components/section'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state'
|
||||
import { Section } from '../../../shared/components/section'
|
||||
import { accessSettingsQueryAtom } from '../state'
|
||||
import { EnvironmentPermissionRow } from './environment-permission-row'
|
||||
|
||||
19
web/features/deployments/detail/access/state.ts
Normal file
19
web/features/deployments/detail/access/state.ts
Normal file
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
|
||||
export const accessSettingsQueryAtom = atomWithQuery((get) => {
|
||||
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
return consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({
|
||||
input: appInstanceId
|
||||
? {
|
||||
params: { appInstanceId },
|
||||
}
|
||||
: skipToken,
|
||||
enabled: Boolean(appInstanceId),
|
||||
})
|
||||
})
|
||||
14
web/features/deployments/detail/api-tokens/README.md
Normal file
14
web/features/deployments/detail/api-tokens/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# Deployment API Tokens
|
||||
|
||||
API tokens route for API token management, endpoint display, and API documentation drawer.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `route-state` | Reads the route app instance identity for API token queries. |
|
||||
| `detail/api-tokens/api-token-management` | Renders API endpoint display, API key creation, and API docs entry points. |
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
@ -0,0 +1,67 @@
|
||||
import type { Getter } from 'jotai'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
|
||||
|
||||
type QueryOptions = {
|
||||
enabled?: boolean
|
||||
input?: unknown
|
||||
queryKey?: readonly unknown[]
|
||||
}
|
||||
|
||||
vi.mock('jotai-tanstack-query', () => ({
|
||||
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
|
||||
...createOptions(get),
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
accessService: {
|
||||
getDeveloperApiSettings: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getDeveloperApiSettings', options.input],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
async function loadState() {
|
||||
return await import('../state')
|
||||
}
|
||||
|
||||
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
|
||||
store.set(setNextRouteStateAtom, {
|
||||
pathname: `/deployments/${appInstanceId}/api-tokens`,
|
||||
params: { appInstanceId },
|
||||
})
|
||||
}
|
||||
|
||||
describe('deployment API tokens state', () => {
|
||||
it('should gate API token queries until a route app instance exists', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
|
||||
setDeploymentRoute(store)
|
||||
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,15 @@
|
||||
# Deployment API Keys
|
||||
|
||||
API key creation, listing, and generated token presentation for the API tokens route.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------------- | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
| `route-state` | Reads the route app instance identity for API key mutations. |
|
||||
| `detail/api-tokens` | Uses API-token-owned query state and table styles. |
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
@ -28,10 +28,8 @@ import {
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../../components/detail-table'
|
||||
import {
|
||||
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
} from '../../components/detail-table-styles'
|
||||
} from '../../../shared/components/detail-table'
|
||||
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles'
|
||||
|
||||
function ApiKeyName({ apiKey }: {
|
||||
apiKey: ApiKey
|
||||
@ -6,7 +6,7 @@ import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitl
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CopyPill } from '../components/endpoint'
|
||||
import { CopyPill } from '../../../shared/components/endpoint'
|
||||
|
||||
function buildCurlExample(apiUrl: string, token: string) {
|
||||
return `curl -X POST '${apiUrl}' \\
|
||||
@ -0,0 +1,17 @@
|
||||
# Deployment API Token Management
|
||||
|
||||
API token management section for API endpoint display, token creation, and token listing.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
| `route-state` | Reads the route app instance identity for API token mutations. |
|
||||
| `detail/api-tokens` | Reads API tokens route query data and table styles. |
|
||||
| `detail/api-tokens/api-keys` | Renders API key creation, listing, and created-token surfaces. |
|
||||
| `detail/api-tokens/docs` | Opens API documentation for the current endpoint. |
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
@ -1,26 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AccessChannels,
|
||||
ApiKey,
|
||||
Environment,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { CopyPill } from '../components/endpoint'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state'
|
||||
import { CopyPill } from '../../../shared/components/endpoint'
|
||||
import { ApiKeyGenerateMenu } from '../api-keys/api-key-generate-menu'
|
||||
import { ApiKeyList } from '../api-keys/api-key-list'
|
||||
import { CreatedApiTokenDialog } from '../api-keys/created-token-dialog'
|
||||
import { DeveloperApiDocsDrawer } from '../docs/docs-drawer'
|
||||
import { developerApiSettingsQueryAtom } from '../state'
|
||||
import { ApiKeyGenerateMenu } from './api-key-generate-menu'
|
||||
import { ApiKeyList } from './api-key-list'
|
||||
import { CreatedApiTokenDialog } from './created-token-dialog'
|
||||
import { DeveloperApiDocsDrawer } from './docs-drawer'
|
||||
import { DeveloperApiSkeleton } from './skeleton'
|
||||
|
||||
type CreatedApiToken = {
|
||||
@ -28,61 +24,6 @@ type CreatedApiToken = {
|
||||
token: string
|
||||
}
|
||||
|
||||
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
|
||||
checked: boolean
|
||||
accessChannels?: AccessChannels
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
|
||||
|
||||
return (
|
||||
<Switch
|
||||
aria-label={t('access.api.developerTitle')}
|
||||
checked={checked}
|
||||
disabled={disabled || !appInstanceId}
|
||||
loading={toggleDeveloperAPI.isPending}
|
||||
onCheckedChange={(enabled) => {
|
||||
if (!appInstanceId)
|
||||
return
|
||||
|
||||
toggleDeveloperAPI.mutate({
|
||||
params: { appInstanceId },
|
||||
body: {
|
||||
appInstanceId,
|
||||
webAppEnabled: accessChannels?.webAppEnabled ?? false,
|
||||
developerApiEnabled: enabled,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiHeaderSwitch() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
|
||||
const accessChannels = developerApiSettingsQuery.data?.accessChannels
|
||||
const apiEnabled = accessChannels?.developerApiEnabled ?? false
|
||||
|
||||
if (developerApiSettingsQuery.isLoading)
|
||||
return <SwitchSkeleton />
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
|
||||
</span>
|
||||
<DeveloperApiSwitch
|
||||
checked={apiEnabled}
|
||||
accessChannels={accessChannels}
|
||||
disabled={developerApiSettingsQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyListSection({ apiKeys, environments, action }: {
|
||||
apiKeys: ApiKey[]
|
||||
environments: Environment[]
|
||||
@ -11,8 +11,8 @@ import {
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../../components/detail-table'
|
||||
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../components/detail-table-styles'
|
||||
} from '../../../shared/components/detail-table'
|
||||
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles'
|
||||
|
||||
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessChannels } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
import { developerApiSettingsQueryAtom } from './state'
|
||||
|
||||
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
|
||||
checked: boolean
|
||||
accessChannels?: AccessChannels
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
|
||||
|
||||
return (
|
||||
<Switch
|
||||
aria-label={t('access.api.developerTitle')}
|
||||
checked={checked}
|
||||
disabled={disabled || !appInstanceId}
|
||||
loading={toggleDeveloperAPI.isPending}
|
||||
onCheckedChange={(enabled) => {
|
||||
if (!appInstanceId)
|
||||
return
|
||||
|
||||
toggleDeveloperAPI.mutate({
|
||||
params: { appInstanceId },
|
||||
body: {
|
||||
appInstanceId,
|
||||
webAppEnabled: accessChannels?.webAppEnabled ?? false,
|
||||
developerApiEnabled: enabled,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiHeaderSwitch() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
|
||||
const accessChannels = developerApiSettingsQuery.data?.accessChannels
|
||||
const apiEnabled = accessChannels?.developerApiEnabled ?? false
|
||||
|
||||
if (developerApiSettingsQuery.isLoading)
|
||||
return <SwitchSkeleton />
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
|
||||
</span>
|
||||
<DeveloperApiSwitch
|
||||
checked={apiEnabled}
|
||||
accessChannels={accessChannels}
|
||||
disabled={developerApiSettingsQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
web/features/deployments/detail/api-tokens/docs/README.md
Normal file
19
web/features/deployments/detail/api-tokens/docs/README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Deployment API Token Docs
|
||||
|
||||
API documentation drawer for the API tokens route.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------- | ------------------------------------------------------------- |
|
||||
| `route-state` | Reads the route app instance identity for documentation copy. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| --------------------------------- | ----------------------------------------------------------- |
|
||||
| `app/components/develop/template` | Reuses localized workflow API documentation templates. |
|
||||
| `context/i18n` | Reads the current locale for documentation template choice. |
|
||||
| `hooks/use-theme` | Reads the current theme for documentation rendering. |
|
||||
| `i18n-config/language` | Maps the current locale to the documentation language. |
|
||||
| `types/app` | Uses app and theme enums for documentation rendering. |
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { DeveloperApiSection } from './section'
|
||||
import { DeveloperApiSection } from './api-token-management/section'
|
||||
|
||||
export function DeveloperApiTab() {
|
||||
export function DeploymentApiTokens() {
|
||||
return (
|
||||
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
|
||||
<DeveloperApiSection />
|
||||
@ -5,19 +5,6 @@ import { atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
|
||||
export const accessSettingsQueryAtom = atomWithQuery((get) => {
|
||||
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
return consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({
|
||||
input: appInstanceId
|
||||
? {
|
||||
params: { appInstanceId },
|
||||
}
|
||||
: skipToken,
|
||||
enabled: Boolean(appInstanceId),
|
||||
})
|
||||
})
|
||||
|
||||
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
|
||||
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
action: 'w-16 py-1.5 whitespace-nowrap',
|
||||
environment: 'whitespace-nowrap',
|
||||
key: '',
|
||||
name: 'whitespace-nowrap',
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
actions: 'w-14 py-1.5 whitespace-nowrap',
|
||||
currentRelease: '',
|
||||
environment: 'whitespace-nowrap',
|
||||
status: 'whitespace-nowrap',
|
||||
}
|
||||
|
||||
export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
action: 'w-14 py-1.5 whitespace-nowrap',
|
||||
author: 'whitespace-nowrap',
|
||||
createdAt: 'whitespace-nowrap',
|
||||
deployedTo: '',
|
||||
release: 'whitespace-nowrap',
|
||||
sourceApp: 'whitespace-nowrap',
|
||||
}
|
||||
|
||||
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
|
||||
action: 'w-16 py-1.5 whitespace-nowrap',
|
||||
environment: 'whitespace-nowrap',
|
||||
key: '',
|
||||
name: 'whitespace-nowrap',
|
||||
}
|
||||
|
||||
export const DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME = cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
|
||||
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)
|
||||
@ -17,9 +17,9 @@ import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skel
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { DeploymentActionsMenu } from '../components/deployment-actions'
|
||||
import { TitleTooltip } from '../components/title-tooltip'
|
||||
import { DeploymentActionsMenu } from '../deployment-actions'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
|
||||
import { TitleTooltip } from '../shared/components/title-tooltip'
|
||||
import { deploymentDetailAppInstanceQueryAtom } from './state'
|
||||
|
||||
type TabDef = {
|
||||
@ -164,7 +164,7 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: {
|
||||
)}
|
||||
</div>
|
||||
<DeploymentActionsMenu
|
||||
appInstanceId={appInstanceId}
|
||||
appInstance={app}
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
className="shrink-0"
|
||||
|
||||
@ -1,25 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { InstanceDetailTabKey } from './tabs'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Link from '@/next/link'
|
||||
import { useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import { CreateReleaseControl } from '../create-release'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
|
||||
import { DeveloperApiHeaderSwitch } from './access-tab/developer-api/section'
|
||||
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
|
||||
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
|
||||
import { versionsTabLocalAtoms } from './versions-tab/state'
|
||||
import { DeveloperApiHeaderSwitch } from './api-tokens/developer-api-header-switch'
|
||||
import { NewDeploymentHeaderAction } from './instances/header-actions/new-deployment-button'
|
||||
import { deploymentDetailActiveTabAtom } from './state'
|
||||
import { INSTANCE_DETAIL_TAB_KEYS } from './tabs'
|
||||
|
||||
function MobileDetailTabs({ appInstanceId, activeTab }: {
|
||||
appInstanceId: string
|
||||
activeTab: InstanceDetailTabKey
|
||||
}) {
|
||||
function MobileDetailTabs() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const activeTab = useAtomValue(deploymentDetailActiveTabAtom)
|
||||
|
||||
if (!appInstanceId)
|
||||
return null
|
||||
|
||||
return (
|
||||
<nav
|
||||
@ -50,9 +49,7 @@ export function InstanceDetail({ children }: {
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const selectedSegment = useSelectedLayoutSegment()
|
||||
const selectedTab = selectedSegment ?? undefined
|
||||
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
|
||||
const activeTab = useAtomValue(deploymentDetailActiveTabAtom)
|
||||
|
||||
useDocumentTitle(t('documentTitle.detail'))
|
||||
|
||||
@ -60,45 +57,37 @@ export function InstanceDetail({ children }: {
|
||||
return null
|
||||
|
||||
return (
|
||||
<ScopeProvider
|
||||
key={appInstanceId}
|
||||
atoms={[
|
||||
...versionsTabLocalAtoms,
|
||||
]}
|
||||
name="DeploymentDetail"
|
||||
>
|
||||
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
|
||||
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
|
||||
<div className="h-full min-w-0 overflow-y-auto">
|
||||
<div className="flex min-h-full w-full flex-col">
|
||||
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
|
||||
{activeTab === 'api-tokens' && (
|
||||
<div className="shrink-0">
|
||||
<DeveloperApiHeaderSwitch />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
|
||||
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
|
||||
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
|
||||
<div className="h-full min-w-0 overflow-y-auto">
|
||||
<div className="flex min-h-full w-full flex-col">
|
||||
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
|
||||
{activeTab === 'api-tokens' && (
|
||||
<div className="shrink-0">
|
||||
<DeveloperApiHeaderSwitch />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(activeTab === 'instances' || activeTab === 'releases') && (
|
||||
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
|
||||
{activeTab === 'instances'
|
||||
? <NewDeploymentHeaderAction />
|
||||
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
|
||||
</div>
|
||||
{(activeTab === 'instances' || activeTab === 'releases') && (
|
||||
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
|
||||
{activeTab === 'instances'
|
||||
? <NewDeploymentHeaderAction />
|
||||
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MobileDetailTabs appInstanceId={appInstanceId} activeTab={activeTab} />
|
||||
{children}
|
||||
</div>
|
||||
<MobileDetailTabs />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScopeProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
18
web/features/deployments/detail/instances/README.md
Normal file
18
web/features/deployments/detail/instances/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Deployment Instances
|
||||
|
||||
Instances route for listing environment deployments and running deployment row actions.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `detail` | Reads detail-owned query state and runtime instance rows before composing sections. |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
| `detail/instances/environment-list` | Renders environment deployment rows. |
|
||||
| `detail/instances/header-actions` | Renders the new deployment action for empty instances. |
|
||||
|
||||
## External Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------------------------ | --------------------------------------------------------------- |
|
||||
| `app/components/base/skeleton` | Reuses skeleton primitives for deployment table loading states. |
|
||||
@ -0,0 +1,15 @@
|
||||
# Deployment Instances Environment List
|
||||
|
||||
Environment list section for rendering deployment rows and row-level status summaries on the instances route.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
| Module | Why this module uses it |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------ |
|
||||
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
|
||||
| `detail/instances` | Uses instances-owned table column class names for deployment rows. |
|
||||
| `detail/instances/row-actions` | Renders row-level deployment actions for each environment. |
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user