diff --git a/web/app/(commonLayout)/deployments/create/page.tsx b/web/app/(commonLayout)/deployments/create/page.tsx new file mode 100644 index 0000000000..1e85fbdffa --- /dev/null +++ b/web/app/(commonLayout)/deployments/create/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import { CreateDeploymentGuide } from '@/features/deployments/create-guide' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function CreateDeploymentPage() { + const { t } = useTranslation('deployments') + useDocumentTitle(t('documentTitle.create')) + + return +} diff --git a/web/features/deployments/create-guide/index.tsx b/web/features/deployments/create-guide/index.tsx new file mode 100644 index 0000000000..187abde0c8 --- /dev/null +++ b/web/features/deployments/create-guide/index.tsx @@ -0,0 +1,1130 @@ +'use client' + +import type { AppDeployEnvironment, DeploymentBindingSlot, DeploymentRuntimeBinding, EnvironmentDeployment, ReleaseSummary } from '@dify/contracts/enterprise/types.gen' +import type { App } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import Input from '@/app/components/base/input' +import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import Link from '@/next/link' +import { consoleQuery } from '@/service/client' +import { toAppMode } from '../app-mode' +import { SOURCE_APPS_PAGE_SIZE } from '../data' +import { environmentMode, environmentName } from '../environment' + +type GuideMethod = 'bindApp' | 'importDsl' +type GuideStep = 'method' | 'source' | 'release' | 'target' | 'review' | 'done' +type EnvironmentOption = AppDeployEnvironment & { id: string } +type BindingSelections = Record + +type BindingSelectOption = { + value: string + label: string +} + +const guideSteps: GuideStep[] = ['method', 'source', 'release', 'target', 'review'] +const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app'] + +const plannedEnvironments: EnvironmentOption[] = [ + { + id: 'env-prod', + name: 'Production', + type: 'isolated', + backend: 'Kubernetes', + status: 'Ready', + }, + { + id: 'env-staging', + name: 'Staging', + type: 'shared', + backend: 'Runner', + status: 'Ready', + }, +] + +const plannedBindingSlots: DeploymentBindingSlot[] = [ + { + slot: 'openai-model', + kind: 'model', + name: 'OpenAI model credential', + required: true, + credentialCandidates: [ + { + credentialId: 'openai-prod', + displayName: 'OpenAI production key', + }, + ], + }, +] + +function hasEnvironmentId(environment?: AppDeployEnvironment): environment is EnvironmentOption { + return Boolean(environment?.id) +} + +function environmentsFromDeployments(rows?: EnvironmentDeployment[]) { + return rows?.map(row => row.environment).filter(hasEnvironmentId) ?? [] +} + +function isEnvBindingSlot(slot: DeploymentBindingSlot) { + return (slot.kind?.toLowerCase() ?? '').includes('env') +} + +function bindingSlotKey(slot: DeploymentBindingSlot) { + return slot.slot ?? '' +} + +function bindingCandidateOptions(slot: DeploymentBindingSlot): BindingSelectOption[] { + if (isEnvBindingSlot(slot)) { + return (slot.envVarCandidates ?? []) + .filter(candidate => candidate.envVarId) + .map(candidate => ({ + value: candidate.envVarId!, + label: [ + candidate.name, + candidate.displayValue, + ].filter(Boolean).join(' · ') || candidate.envVarId!, + })) + } + + return (slot.credentialCandidates ?? []) + .filter(candidate => candidate.credentialId) + .map(candidate => ({ + value: candidate.credentialId!, + label: [ + candidate.displayName, + candidate.pluginName || candidate.pluginId, + candidate.pluginVersion, + ].filter(Boolean).join(' · ') || candidate.credentialId!, + })) +} + +function hasMissingRequiredBinding(slot: DeploymentBindingSlot, selectedValue?: string) { + return Boolean(slot.required && !selectedValue) +} + +function selectedBindingSelections(slots: DeploymentBindingSlot[], manualBindings: BindingSelections): BindingSelections { + const next: BindingSelections = {} + for (const slot of slots) { + const slotKey = bindingSlotKey(slot) + const candidates = bindingCandidateOptions(slot) + const existing = manualBindings[slotKey] + if (existing && candidates.some(candidate => candidate.value === existing)) + next[slotKey] = existing + else if (candidates.length === 1 && candidates[0]) + next[slotKey] = candidates[0].value + } + return next +} + +function selectedDeploymentBindings(slots: DeploymentBindingSlot[], selections: BindingSelections): DeploymentRuntimeBinding[] { + return slots + .map((slot): DeploymentRuntimeBinding | undefined => { + const slotKey = bindingSlotKey(slot) + const selectedValue = selections[slotKey] + if (!slotKey || !selectedValue) + return undefined + + return isEnvBindingSlot(slot) + ? { slot: slotKey, envVarId: selectedValue } + : { slot: slotKey, credentialId: selectedValue } + }) + .filter((binding): binding is DeploymentRuntimeBinding => Boolean(binding)) +} + +function sourceAppSearchText(app: App) { + return `${app.name} ${app.id} ${app.mode}`.toLowerCase() +} + +function StepShell({ title, description, children }: { + title: string + description: string + children: React.ReactNode +}) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ) +} + +function StepList({ activeStep }: { + activeStep: GuideStep +}) { + const { t } = useTranslation('deployments') + const activeIndex = guideSteps.indexOf(activeStep) + + return ( +
    + {guideSteps.map((step, index) => { + const isActive = step === activeStep + const isDone = activeIndex > index || activeStep === 'done' + return ( +
  1. + + {isDone ? + + {t(`createGuide.steps.${step}`)} + +
  2. + ) + })} +
+ ) +} + +function MethodCard({ icon, title, description, badge, selected, onClick }: { + icon: string + title: string + description: string + badge?: string + selected: boolean + onClick: () => void +}) { + return ( + + ) +} + +function MethodStep({ method, onSelect }: { + method?: GuideMethod + onSelect: (method: GuideMethod) => void +}) { + const { t } = useTranslation('deployments') + + return ( + +
+ onSelect('bindApp')} + /> + onSelect('importDsl')} + /> +
+
+ ) +} + +function SourceAppSkeleton() { + return ( +
+ {sourceAppSkeletonKeys.map(key => ( + + +
+ + +
+
+ ))} +
+ ) +} + +function SourceAppOption({ app, selected, onSelect }: { + app: App + selected: boolean + onSelect: () => void +}) { + const { t } = useTranslation('deployments') + const mode = toAppMode(app.mode) + + return ( + + ) +} + +function SourceStep({ + apps, + selectedApp, + searchText, + isLoading, + onSearchTextChange, + onSelectApp, +}: { + apps: App[] + selectedApp?: App + searchText: string + isLoading: boolean + onSearchTextChange: (value: string) => void + onSelectApp: (app: App) => void +}) { + const { t } = useTranslation('deployments') + const effectiveSelectedAppId = selectedApp?.id ?? apps[0]?.id + const filteredApps = searchText.trim() + ? apps.filter(app => sourceAppSearchText(app).includes(searchText.trim().toLowerCase())) + : apps + + return ( + +
+ + onSearchTextChange(event.target.value)} + placeholder={t('createGuide.source.searchPlaceholder')} + showLeftIcon + showClearIcon + onClear={() => onSearchTextChange('')} + className="h-8" + /> + {isLoading + ? + : filteredApps.length === 0 + ? ( +
+ {t('createGuide.source.empty')} +
+ ) + : ( +
+ {filteredApps.map(app => ( + onSelectApp(app)} + /> + ))} +
+ )} +
+
+ ) +} + +function DslStep() { + const { t } = useTranslation('deployments') + + return ( + +
+
+
+
+ app.workflow.yaml +
+
+
+ ) +} + +function ReleaseStep({ + instanceName, + releaseName, + releaseDescription, + onInstanceNameChange, + onReleaseNameChange, + onReleaseDescriptionChange, +}: { + instanceName: string + releaseName: string + releaseDescription: string + onInstanceNameChange: (value: string) => void + onReleaseNameChange: (value: string) => void + onReleaseDescriptionChange: (value: string) => void +}) { + const { t } = useTranslation('deployments') + + return ( + +
+
+ + onInstanceNameChange(event.target.value)} + required + className="h-9" + /> +
+
+ + onReleaseNameChange(event.target.value)} + required + className="h-9" + /> +
+
+ +