mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
feat(app-deploy): wire release deployment UI
This commit is contained in:
parent
b305e8b65d
commit
141d936e91
@ -36,6 +36,7 @@ import {
|
||||
zEnterpriseAppDeployConsoleListAppInstancesQuery,
|
||||
zEnterpriseAppDeployConsoleListAppInstancesResponse,
|
||||
zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath,
|
||||
zEnterpriseAppDeployConsoleListDeploymentBindingOptionsQuery,
|
||||
zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse,
|
||||
zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse,
|
||||
zEnterpriseAppDeployConsoleListReleasesPath,
|
||||
@ -46,6 +47,8 @@ import {
|
||||
zEnterpriseAppDeployConsolePreviewReleaseBody,
|
||||
zEnterpriseAppDeployConsolePreviewReleasePath,
|
||||
zEnterpriseAppDeployConsolePreviewReleaseResponse,
|
||||
zEnterpriseAppDeployConsoleRevealDeveloperApiKeyPath,
|
||||
zEnterpriseAppDeployConsoleRevealDeveloperApiKeyResponse,
|
||||
zEnterpriseAppDeployConsoleSearchAccessSubjectsPath,
|
||||
zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery,
|
||||
zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse,
|
||||
@ -197,6 +200,17 @@ export const deleteDeveloperApiKey = oc
|
||||
.input(z.object({ params: zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath }))
|
||||
.output(zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse)
|
||||
|
||||
export const revealDeveloperApiKey = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'EnterpriseAppDeployConsole_RevealDeveloperApiKey',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}:reveal',
|
||||
tags: ['EnterpriseAppDeployConsole'],
|
||||
})
|
||||
.input(z.object({ params: zEnterpriseAppDeployConsoleRevealDeveloperApiKeyPath }))
|
||||
.output(zEnterpriseAppDeployConsoleRevealDeveloperApiKeyResponse)
|
||||
|
||||
export const listDeploymentBindingOptions = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
@ -205,7 +219,12 @@ export const listDeploymentBindingOptions = oc
|
||||
path: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options',
|
||||
tags: ['EnterpriseAppDeployConsole'],
|
||||
})
|
||||
.input(z.object({ params: zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath }))
|
||||
.input(
|
||||
z.object({
|
||||
params: zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath,
|
||||
query: zEnterpriseAppDeployConsoleListDeploymentBindingOptionsQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse)
|
||||
|
||||
export const createDeployment = oc
|
||||
@ -400,6 +419,7 @@ export const appDeploy = {
|
||||
searchAccessSubjects,
|
||||
createDeveloperApiKey,
|
||||
deleteDeveloperApiKey,
|
||||
revealDeveloperApiKey,
|
||||
listDeploymentBindingOptions,
|
||||
createDeployment,
|
||||
updateDeveloperApi,
|
||||
|
||||
@ -29,6 +29,7 @@ export type AccessStatus = {
|
||||
cliUrl?: string
|
||||
developerApiEnabled?: boolean
|
||||
apiKeyCount?: number
|
||||
apiUrl?: string
|
||||
}
|
||||
|
||||
export type AccessSubject = {
|
||||
@ -92,6 +93,10 @@ export type AppInstanceBasicInfo = {
|
||||
sourceAppName?: string
|
||||
mode?: string
|
||||
createdAt?: string
|
||||
sourceAppAvailable?: boolean
|
||||
canCreateRelease?: boolean
|
||||
icon?: string
|
||||
iconBackground?: string
|
||||
}
|
||||
|
||||
export type AppInstanceCard = {
|
||||
@ -102,6 +107,9 @@ export type AppInstanceCard = {
|
||||
sourceAppName?: string
|
||||
statuses?: Array<StatusCount>
|
||||
lastDeployedAt?: string
|
||||
sourceAppAvailable?: boolean
|
||||
canCreateRelease?: boolean
|
||||
iconBackground?: string
|
||||
}
|
||||
|
||||
export type AppRunnerBatchRuntimeArtifactReply = {
|
||||
@ -256,7 +264,6 @@ export type ConsoleUser = {
|
||||
|
||||
export type CreateAppInstanceReply = {
|
||||
appInstanceId?: string
|
||||
initialRelease?: ConsoleRelease
|
||||
}
|
||||
|
||||
export type CreateAppInstanceReq = {
|
||||
@ -486,6 +493,7 @@ export type DeploymentStatusRow = {
|
||||
export type DeveloperApiAccess = {
|
||||
enabled?: boolean
|
||||
apiKeys?: Array<DeveloperApiKeyRow>
|
||||
apiUrl?: string
|
||||
}
|
||||
|
||||
export type DeveloperApiKeyRow = {
|
||||
@ -1065,6 +1073,10 @@ export type RetryEnvironmentReq = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export type RevealDeveloperApiKeyReply = {
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type RuntimeEndpoints = {
|
||||
run?: string
|
||||
health?: string
|
||||
@ -1609,12 +1621,31 @@ export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses = {
|
||||
export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse
|
||||
= EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses]
|
||||
|
||||
export type EnterpriseAppDeployConsoleRevealDeveloperApiKeyData = {
|
||||
body?: never
|
||||
path: {
|
||||
appInstanceId: string
|
||||
apiKeyId: string
|
||||
}
|
||||
query?: never
|
||||
url: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}:reveal'
|
||||
}
|
||||
|
||||
export type EnterpriseAppDeployConsoleRevealDeveloperApiKeyResponses = {
|
||||
200: RevealDeveloperApiKeyReply
|
||||
}
|
||||
|
||||
export type EnterpriseAppDeployConsoleRevealDeveloperApiKeyResponse
|
||||
= EnterpriseAppDeployConsoleRevealDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleRevealDeveloperApiKeyResponses]
|
||||
|
||||
export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
appInstanceId: string
|
||||
}
|
||||
query?: never
|
||||
query?: {
|
||||
releaseId?: string
|
||||
}
|
||||
url: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options'
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ export const zAccessStatus = z.object({
|
||||
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
|
||||
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
|
||||
.optional(),
|
||||
apiUrl: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zAccessSubject = z.object({
|
||||
@ -88,6 +89,10 @@ export const zAppInstanceBasicInfo = z.object({
|
||||
sourceAppName: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
createdAt: z.iso.datetime().optional(),
|
||||
sourceAppAvailable: z.boolean().optional(),
|
||||
canCreateRelease: z.boolean().optional(),
|
||||
icon: z.string().optional(),
|
||||
iconBackground: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zAppRunnerBootstrapAssignment = z.object({
|
||||
@ -228,7 +233,6 @@ export const zConsoleUser = z.object({
|
||||
|
||||
export const zCreateAppInstanceReply = z.object({
|
||||
appInstanceId: z.string().optional(),
|
||||
initialRelease: zConsoleRelease.optional(),
|
||||
})
|
||||
|
||||
export const zCreateAppInstanceReq = z.object({
|
||||
@ -445,6 +449,7 @@ export const zCreateDeveloperApiKeyReply = z.object({
|
||||
export const zDeveloperApiAccess = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
apiKeys: z.array(zDeveloperApiKeyRow).optional(),
|
||||
apiUrl: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -1010,6 +1015,10 @@ export const zRetryEnvironmentReq = z.object({
|
||||
id: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zRevealDeveloperApiKeyReply = z.object({
|
||||
token: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zRuntimeEndpoints = z.object({
|
||||
run: z.string().optional(),
|
||||
health: z.string().optional(),
|
||||
@ -1139,6 +1148,9 @@ export const zAppInstanceCard = z.object({
|
||||
sourceAppName: z.string().optional(),
|
||||
statuses: z.array(zStatusCount).optional(),
|
||||
lastDeployedAt: z.iso.datetime().optional(),
|
||||
sourceAppAvailable: z.boolean().optional(),
|
||||
canCreateRelease: z.boolean().optional(),
|
||||
iconBackground: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zSubjectAccountData = z.object({
|
||||
@ -1711,10 +1723,24 @@ export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath = z.object({
|
||||
*/
|
||||
export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse = zDeleteDeveloperApiKeyReply
|
||||
|
||||
export const zEnterpriseAppDeployConsoleRevealDeveloperApiKeyPath = z.object({
|
||||
appInstanceId: z.string(),
|
||||
apiKeyId: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
export const zEnterpriseAppDeployConsoleRevealDeveloperApiKeyResponse = zRevealDeveloperApiKeyReply
|
||||
|
||||
export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath = z.object({
|
||||
appInstanceId: z.string(),
|
||||
})
|
||||
|
||||
export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsQuery = z.object({
|
||||
releaseId: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
|
||||
@ -204,7 +204,6 @@ const CreateInstanceForm: FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const createInstance = useCreateDeploymentInstance()
|
||||
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
|
||||
const { data: appList, isLoading } = useAppList({ page: 1, limit: MAX_STUDIO_SOURCE_APPS, name: '' })
|
||||
const apps = useMemo<AppInfo[]>(() => {
|
||||
return (appList?.data ?? []).map(toStudioSourceAppInfo)
|
||||
@ -218,7 +217,7 @@ const CreateInstanceForm: FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const selectedApp = apps.find(a => a.id === appId)
|
||||
const canCreate = Boolean(appId && name.trim() && !isSubmitting)
|
||||
|
||||
const handleCreate = async (thenDeploy: boolean) => {
|
||||
const handleCreate = async () => {
|
||||
if (!canCreate)
|
||||
return
|
||||
|
||||
@ -230,13 +229,6 @@ const CreateInstanceForm: FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
description: description.trim() || undefined,
|
||||
})
|
||||
onClose()
|
||||
if (thenDeploy) {
|
||||
openDeployDrawer({
|
||||
appInstanceId: result.appInstanceId,
|
||||
releaseId: result.initialRelease?.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push(`/deployments/${result.appInstanceId}/overview`)
|
||||
}
|
||||
catch {
|
||||
@ -299,12 +291,9 @@ const CreateInstanceForm: FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('createModal.cancel')}
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!canCreate} onClick={() => void handleCreate(false)}>
|
||||
<Button variant="primary" disabled={!canCreate} onClick={() => void handleCreate()}>
|
||||
{t('createModal.create')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!canCreate} onClick={() => void handleCreate(true)}>
|
||||
{t('createModal.createAndDeploy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -62,15 +63,21 @@ const DeployDrawer: FC = () => {
|
||||
defaultReleaseId={defaultReleaseId}
|
||||
lockedEnvId={drawer.environmentId}
|
||||
presetReleaseId={drawer.releaseId}
|
||||
isSubmitting={startDeploy.isPending}
|
||||
onCancel={closeDeployDrawer}
|
||||
onSubmit={({ environmentId, releaseId, releaseNote }) => {
|
||||
closeDeployDrawer()
|
||||
startDeploy.mutate({
|
||||
appInstanceId: drawerAppInstanceId,
|
||||
environmentId,
|
||||
releaseId,
|
||||
releaseNote,
|
||||
})
|
||||
onSubmit={async ({ environmentId, releaseId, bindings }) => {
|
||||
try {
|
||||
await startDeploy.mutateAsync({
|
||||
appInstanceId: drawerAppInstanceId,
|
||||
environmentId,
|
||||
releaseId,
|
||||
bindings,
|
||||
})
|
||||
closeDeployDrawer()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('deployDrawer.deployFailed'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,24 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { DeploymentBindingOptionSlot, DeploymentRuntimeBinding } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { ConsoleReleaseSummary, EnvironmentOption, RuntimeBindingDisplay } from '@/features/deployments/types'
|
||||
import type { ConsoleReleaseSummary, EnvironmentOption } from '@/features/deployments/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
environmentMode,
|
||||
environmentName,
|
||||
isRuntimeEnvVarBinding,
|
||||
isRuntimeModelBinding,
|
||||
isRuntimePluginBinding,
|
||||
releaseCommit,
|
||||
releaseLabel,
|
||||
runtimeBindingLabel,
|
||||
runtimeBindingValue,
|
||||
} from '../../utils'
|
||||
import {
|
||||
DeploymentSelect,
|
||||
@ -28,8 +23,8 @@ import {
|
||||
|
||||
export type DeployFormSubmit = {
|
||||
environmentId: string
|
||||
releaseId?: string
|
||||
releaseNote?: string
|
||||
releaseId: string
|
||||
bindings: DeploymentRuntimeBinding[]
|
||||
}
|
||||
|
||||
type DeployFormProps = {
|
||||
@ -39,41 +34,160 @@ type DeployFormProps = {
|
||||
defaultReleaseId?: string
|
||||
lockedEnvId?: string
|
||||
presetReleaseId?: string
|
||||
isSubmitting?: boolean
|
||||
onCancel: () => void
|
||||
onSubmit: (params: DeployFormSubmit) => void
|
||||
onSubmit: (params: DeployFormSubmit) => void | Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeBindingGroupProps = {
|
||||
type BindingSelections = Record<string, string>
|
||||
|
||||
type BindingSelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
bindings: RuntimeBindingDisplay[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const RuntimeBindingGroup: FC<RuntimeBindingGroupProps> = ({ label, bindings, isLoading }) => {
|
||||
type BindingOptionsPanelProps = {
|
||||
slots: DeploymentBindingOptionSlot[]
|
||||
selections: BindingSelections
|
||||
isLoading: boolean
|
||||
hasError: boolean
|
||||
onChange: (slot: string, value: string) => void
|
||||
}
|
||||
|
||||
const isEnvBindingSlot = (slot: DeploymentBindingOptionSlot) =>
|
||||
(slot.kind?.toLowerCase() ?? '').includes('env')
|
||||
|
||||
const bindingSlotKey = (slot: DeploymentBindingOptionSlot) => slot.slot ?? ''
|
||||
|
||||
const bindingCandidateOptions = (slot: DeploymentBindingOptionSlot): 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.candidates ?? [])
|
||||
.filter(candidate => candidate.credentialId)
|
||||
.map(candidate => ({
|
||||
value: candidate.credentialId!,
|
||||
label: [
|
||||
candidate.displayName,
|
||||
candidate.pluginName || candidate.pluginId,
|
||||
candidate.pluginVersion,
|
||||
].filter(Boolean).join(' · ') || candidate.credentialId!,
|
||||
}))
|
||||
}
|
||||
|
||||
const hasMissingRequiredBinding = (slot: DeploymentBindingOptionSlot, selectedValue?: string) =>
|
||||
Boolean(slot.required && !selectedValue)
|
||||
|
||||
const selectedDeploymentBindings = (
|
||||
slots: DeploymentBindingOptionSlot[],
|
||||
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))
|
||||
}
|
||||
|
||||
const BindingOptionsPanel: FC<BindingOptionsPanelProps> = ({
|
||||
slots,
|
||||
selections,
|
||||
isLoading,
|
||||
hasError,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 border-t border-divider-subtle px-3 py-2.5 first:border-t-0">
|
||||
<div className="w-36 shrink-0 system-xs-medium-uppercase text-text-tertiary">{label}</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
{isLoading
|
||||
? <span className="system-sm-regular text-text-quaternary">{t('deployDrawer.loadingBindings')}</span>
|
||||
: bindings.length === 0
|
||||
? <span className="system-sm-regular text-text-quaternary">{t('deployDrawer.noBindingRequired')}</span>
|
||||
: bindings.map(binding => (
|
||||
<div
|
||||
key={`${binding.kind}-${runtimeBindingLabel(binding)}-${runtimeBindingValue(binding)}-${binding.valueType ?? ''}`}
|
||||
className="flex min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
<span className="min-w-0 truncate system-sm-medium text-text-secondary" title={runtimeBindingLabel(binding)}>
|
||||
{runtimeBindingLabel(binding)}
|
||||
</span>
|
||||
<span className="max-w-[240px] truncate rounded-md bg-background-default px-2 py-0.5 font-mono system-xs-medium text-text-tertiary" title={runtimeBindingValue(binding)}>
|
||||
{runtimeBindingValue(binding)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-quaternary">
|
||||
{t('deployDrawer.loadingBindings')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive">
|
||||
{t('deployDrawer.bindingOptionsFailed')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
|
||||
<span className="system-xs-regular text-text-quaternary">{t('deployDrawer.bindingSelectionHint')}</span>
|
||||
</div>
|
||||
{slots.length === 0
|
||||
? (
|
||||
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
|
||||
{t('deployDrawer.noBindingRequired')}
|
||||
</div>
|
||||
)
|
||||
: slots.map((slot) => {
|
||||
const slotKey = bindingSlotKey(slot)
|
||||
const candidates = bindingCandidateOptions(slot)
|
||||
const selectedValue = selections[slotKey] ?? ''
|
||||
const missing = hasMissingRequiredBinding(slot, selectedValue)
|
||||
return (
|
||||
<div key={slotKey} className="flex flex-col gap-2 border-t border-divider-subtle px-3 py-3">
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.9fr)] sm:items-start">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate system-sm-medium text-text-secondary" title={slot.label || slotKey}>
|
||||
{slot.label || slotKey}
|
||||
</span>
|
||||
{slot.required && (
|
||||
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('deployDrawer.requiredBinding')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono system-xs-regular break-all text-text-quaternary" title={slotKey}>
|
||||
{slotKey}
|
||||
</span>
|
||||
</div>
|
||||
{candidates.length === 0
|
||||
? (
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary">
|
||||
{t('deployDrawer.noCredentialCandidates')}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedValue}
|
||||
onChange={value => onChange(slotKey, value)}
|
||||
options={candidates}
|
||||
placeholder={t('deployDrawer.selectCredential')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{missing && (
|
||||
<div className="system-xs-regular text-text-destructive">
|
||||
{t('deployDrawer.missingRequiredBinding')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -85,6 +199,7 @@ export const DeployForm: FC<DeployFormProps> = ({
|
||||
defaultReleaseId,
|
||||
lockedEnvId,
|
||||
presetReleaseId,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}) => {
|
||||
@ -101,34 +216,71 @@ export const DeployForm: FC<DeployFormProps> = ({
|
||||
)
|
||||
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
|
||||
const selectedEnvironment = environments.find(env => env.id === selectedEnvironmentId)
|
||||
const [releaseNote, setReleaseNote] = useState<string>('')
|
||||
const canDeploy = Boolean(selectedEnvironmentId && selectedEnvironment && !selectedEnvironment.disabled && (!isPromote || displayedRelease?.id || defaultReleaseId))
|
||||
const previewReleaseId = isPromote ? displayedRelease?.id ?? defaultReleaseId : undefined
|
||||
const releasePreview = useQuery(consoleQuery.enterprise.appDeploy.previewRelease.queryOptions({
|
||||
input: appInstanceId && (!isPromote || previewReleaseId)
|
||||
const [selectedReleaseId, setSelectedReleaseId] = useState<string>(
|
||||
() => displayedRelease?.id ?? defaultReleaseId ?? '',
|
||||
)
|
||||
const selectedRelease = releases.find(release => release.id === selectedReleaseId)
|
||||
const targetReleaseId = displayedRelease?.id ?? selectedRelease?.id ?? selectedReleaseId
|
||||
const bindingOptions = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentBindingOptions.queryOptions({
|
||||
input: appInstanceId && targetReleaseId
|
||||
? {
|
||||
params: { appInstanceId },
|
||||
body: {
|
||||
releaseId: previewReleaseId,
|
||||
query: {
|
||||
releaseId: targetReleaseId,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
}))
|
||||
const previewBindings = releasePreview.data?.bindings ?? []
|
||||
const modelBindings = previewBindings.filter(isRuntimeModelBinding)
|
||||
const pluginBindings = previewBindings.filter(isRuntimePluginBinding)
|
||||
const envVarBindings = previewBindings.filter(isRuntimeEnvVarBinding)
|
||||
const bindingSlots = useMemo(
|
||||
() => bindingOptions.data?.slots?.filter(slot => slot.slot) ?? [],
|
||||
[bindingOptions.data?.slots],
|
||||
)
|
||||
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
|
||||
const selectedBindings = useMemo(() => {
|
||||
const next: BindingSelections = {}
|
||||
for (const slot of bindingSlots) {
|
||||
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
|
||||
}, [bindingSlots, manualBindings])
|
||||
const deploymentBindings = useMemo(
|
||||
() => selectedDeploymentBindings(bindingSlots, selectedBindings),
|
||||
[bindingSlots, selectedBindings],
|
||||
)
|
||||
const bindingOptionsLoading = Boolean(targetReleaseId && (bindingOptions.isLoading || bindingOptions.isFetching))
|
||||
const bindingOptionsReady = Boolean(targetReleaseId && bindingOptions.data && !bindingOptionsLoading && !bindingOptions.isError)
|
||||
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredBinding(slot, selectedBindings[bindingSlotKey(slot)]))
|
||||
const canDeploy = Boolean(
|
||||
selectedEnvironmentId
|
||||
&& selectedEnvironment
|
||||
&& !selectedEnvironment.disabled
|
||||
&& targetReleaseId
|
||||
&& bindingOptionsReady
|
||||
&& requiredBindingsReady
|
||||
&& !isSubmitting,
|
||||
)
|
||||
|
||||
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
|
||||
const submitLabel = isSubmitting
|
||||
? t('deployDrawer.deploying')
|
||||
: isPromote
|
||||
? t('deployDrawer.promote')
|
||||
: t('deployDrawer.deploy')
|
||||
|
||||
const handleDeploy = () => {
|
||||
if (!canDeploy)
|
||||
if (!canDeploy || !targetReleaseId)
|
||||
return
|
||||
|
||||
onSubmit({
|
||||
environmentId: selectedEnvironmentId,
|
||||
releaseId: displayedRelease?.id ?? (isPromote ? defaultReleaseId : undefined),
|
||||
releaseNote: isPromote ? undefined : releaseNote,
|
||||
releaseId: targetReleaseId,
|
||||
bindings: deploymentBindings,
|
||||
})
|
||||
}
|
||||
|
||||
@ -143,7 +295,7 @@ export const DeployForm: FC<DeployFormProps> = ({
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<Field label={isPromote ? t('deployDrawer.releaseLabel') : t('deployDrawer.noteLabel')}>
|
||||
<Field label={t('deployDrawer.releaseLabel')}>
|
||||
{isPromote && displayedRelease
|
||||
? (
|
||||
<div className="flex flex-col gap-1">
|
||||
@ -166,19 +318,23 @@ export const DeployForm: FC<DeployFormProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={releaseNote}
|
||||
onChange={e => setReleaseNote(e.target.value)}
|
||||
placeholder={t('deployDrawer.notePlaceholder')}
|
||||
maxLength={80}
|
||||
: releases.length === 0
|
||||
? (
|
||||
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-3 py-3 system-sm-regular text-text-tertiary">
|
||||
{t('deployDrawer.noReleaseAvailable')}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedReleaseId}
|
||||
onChange={setSelectedReleaseId}
|
||||
options={releases.filter(release => release.id).map(release => ({
|
||||
value: release.id!,
|
||||
label: `${releaseLabel(release)} · ${releaseCommit(release)}`,
|
||||
}))}
|
||||
placeholder={t('deployDrawer.selectRelease')}
|
||||
/>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('deployDrawer.newReleaseHint')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
@ -202,39 +358,22 @@ export const DeployForm: FC<DeployFormProps> = ({
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle">
|
||||
<div className="flex items-start justify-between gap-3 px-3 py-2.5">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
|
||||
<span className="system-xs-regular text-text-quaternary">{t('deployDrawer.bindingsDisabled')}</span>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-background-default px-2 py-0.5 system-xs-medium text-text-tertiary">
|
||||
{t('deployDrawer.readOnly')}
|
||||
</span>
|
||||
</div>
|
||||
<RuntimeBindingGroup
|
||||
label={t('deployDrawer.modelCreds')}
|
||||
bindings={modelBindings}
|
||||
isLoading={releasePreview.isFetching}
|
||||
{targetReleaseId && (
|
||||
<BindingOptionsPanel
|
||||
slots={bindingSlots}
|
||||
selections={selectedBindings}
|
||||
isLoading={bindingOptionsLoading}
|
||||
hasError={bindingOptions.isError}
|
||||
onChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
|
||||
/>
|
||||
<RuntimeBindingGroup
|
||||
label={t('deployDrawer.pluginCreds')}
|
||||
bindings={pluginBindings}
|
||||
isLoading={releasePreview.isFetching}
|
||||
/>
|
||||
<RuntimeBindingGroup
|
||||
label={t('deployDrawer.envVars')}
|
||||
bindings={envVarBindings}
|
||||
isLoading={releasePreview.isFetching}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t('deployDrawer.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
|
||||
{isPromote ? t('deployDrawer.promote') : t('deployDrawer.deploy')}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -54,7 +54,7 @@ export const DeploymentSelect: FC<SelectProps> = ({ value, onChange, options, pl
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'h-8 border-[0.5px] border-components-input-border-active px-2 system-sm-medium',
|
||||
'h-8 min-w-0 border-[0.5px] border-components-input-border-active px-2 text-left system-sm-medium',
|
||||
!selectedOption && 'text-text-quaternary',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -84,6 +84,7 @@ const RollbackModal: FC = () => {
|
||||
appInstanceId: modal.appInstanceId,
|
||||
environmentId: modal.environmentId,
|
||||
releaseId: modal.targetReleaseId,
|
||||
bindings: [],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
} from '@/features/deployments/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useGenerateDeploymentApiKey,
|
||||
useRevokeDeploymentApiKey,
|
||||
@ -105,6 +105,17 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
|
||||
},
|
||||
})
|
||||
}
|
||||
const handleCopyApiKey = async (apiKeyId: string) => {
|
||||
const response = await consoleClient.enterprise.appDeploy.revealDeveloperApiKey({
|
||||
params: {
|
||||
appInstanceId: appId,
|
||||
apiKeyId,
|
||||
},
|
||||
})
|
||||
if (!response.token)
|
||||
throw new Error('Reveal developer API key did not return a token.')
|
||||
return response.token
|
||||
}
|
||||
const handleSetEnvironmentAccessPolicy = async (
|
||||
appId: string,
|
||||
environmentId: string,
|
||||
@ -150,6 +161,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
|
||||
/>
|
||||
<DeveloperApiSection
|
||||
apiEnabled={apiEnabled}
|
||||
apiUrl={accessConfig?.developerApi?.apiUrl}
|
||||
environments={deployedEnvs}
|
||||
apiKeys={apiKeys}
|
||||
createdToken={visibleCreatedApiToken}
|
||||
@ -158,6 +170,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
|
||||
body: { enabled },
|
||||
})}
|
||||
onGenerate={handleGenerateApiKey}
|
||||
onCopyApiKey={handleCopyApiKey}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onClearCreatedToken={() => setCreatedApiToken(undefined)}
|
||||
/>
|
||||
|
||||
@ -16,18 +16,23 @@ import { environmentName } from '../../utils'
|
||||
|
||||
type ApiKeyRowProps = {
|
||||
apiKey: DeveloperAPIKeySummary
|
||||
onCopy: (apiKeyId: string) => Promise<string>
|
||||
onRevoke: () => void
|
||||
}
|
||||
|
||||
export const ApiKeyRow: FC<ApiKeyRowProps> = ({ apiKey, onRevoke }) => {
|
||||
export const ApiKeyRow: FC<ApiKeyRowProps> = ({ apiKey, onCopy, onRevoke }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const displayValue = apiKey.maskedKey || apiKey.maskedPrefix || apiKey.id || '—'
|
||||
const environmentLabel = apiKey.environment?.name || apiKey.environmentName || apiKey.environmentId || apiKey.environment?.id
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!apiKey.id)
|
||||
return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayValue)
|
||||
const token = await onCopy(apiKey.id)
|
||||
await navigator.clipboard.writeText(token)
|
||||
setCopied(true)
|
||||
toast.success(t('access.copyToast'))
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
|
||||
@ -9,22 +9,26 @@ import { CopyPill, Section } from './common'
|
||||
|
||||
type DeveloperApiSectionProps = {
|
||||
apiEnabled: boolean
|
||||
apiUrl?: string
|
||||
environments: ConsoleEnvironmentSummary[]
|
||||
apiKeys: DeveloperAPIKeySummary[]
|
||||
createdToken?: string
|
||||
onToggle: (enabled: boolean) => void
|
||||
onGenerate: (environmentId: string) => void
|
||||
onCopyApiKey: (apiKeyId: string) => Promise<string>
|
||||
onRevoke: (environmentId: string, apiKeyId: string) => void
|
||||
onClearCreatedToken: () => void
|
||||
}
|
||||
|
||||
export const DeveloperApiSection: FC<DeveloperApiSectionProps> = ({
|
||||
apiEnabled,
|
||||
apiUrl,
|
||||
environments,
|
||||
apiKeys,
|
||||
createdToken,
|
||||
onToggle,
|
||||
onGenerate,
|
||||
onCopyApiKey,
|
||||
onRevoke,
|
||||
onClearCreatedToken,
|
||||
}) => {
|
||||
@ -44,6 +48,12 @@ export const DeveloperApiSection: FC<DeveloperApiSectionProps> = ({
|
||||
{apiEnabled
|
||||
? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{apiUrl && (
|
||||
<CopyPill
|
||||
label={t('access.api.endpoint')}
|
||||
value={apiUrl}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">
|
||||
@ -102,6 +112,7 @@ export const DeveloperApiSection: FC<DeveloperApiSectionProps> = ({
|
||||
<ApiKeyRow
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
onCopy={onCopyApiKey}
|
||||
onRevoke={() => onRevoke(environmentId, apiKey.id!)}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -9,7 +9,10 @@ import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { StatusBadge } from '../components/status-badge'
|
||||
import { deploymentOverviewQueryOptions } from '../queries'
|
||||
import {
|
||||
deploymentOverviewQueryOptions,
|
||||
deploymentReleaseHistoryQueryOptions,
|
||||
} from '../queries'
|
||||
import { useDeploymentsStore } from '../store'
|
||||
import {
|
||||
releaseLabel,
|
||||
@ -56,16 +59,18 @@ type AccessOverviewRowProps = {
|
||||
label: string
|
||||
enabled: boolean
|
||||
hint?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
const AccessOverviewRow: FC<AccessOverviewRowProps> = ({ label, enabled, hint }) => {
|
||||
const AccessOverviewRow: FC<AccessOverviewRowProps> = ({ label, enabled, hint, meta }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">{label}</span>
|
||||
{hint && <span className="truncate system-xs-regular text-text-tertiary">{hint}</span>}
|
||||
{hint && <span className="system-xs-regular break-all text-text-tertiary">{hint}</span>}
|
||||
{meta && <span className="system-xs-regular text-text-quaternary">{meta}</span>}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 system-xs-medium',
|
||||
@ -98,6 +103,7 @@ const OverviewTab: FC<OverviewTabProps> = ({ instanceId }) => {
|
||||
const router = useRouter()
|
||||
const input = { params: { appInstanceId: instanceId } }
|
||||
const { data: overview } = useQuery(deploymentOverviewQueryOptions(instanceId))
|
||||
const { data: releaseHistory } = useQuery(deploymentReleaseHistoryQueryOptions(instanceId))
|
||||
const { data: accessConfig } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
|
||||
input,
|
||||
}))
|
||||
@ -108,6 +114,8 @@ const OverviewTab: FC<OverviewTabProps> = ({ instanceId }) => {
|
||||
() => overview?.deployments?.filter(row => row.environment?.id && row.status?.toLowerCase() !== 'undeployed') ?? [],
|
||||
[overview?.deployments],
|
||||
)
|
||||
const releaseRows = releaseHistory?.data?.filter(row => row.id) ?? []
|
||||
const canCreateRelease = overviewApp?.canCreateRelease ?? true
|
||||
|
||||
if (!app)
|
||||
return null
|
||||
@ -119,6 +127,7 @@ const OverviewTab: FC<OverviewTabProps> = ({ instanceId }) => {
|
||||
const appModeLabel = getAppModeLabel(overviewApp?.mode ?? app.mode, tCommon)
|
||||
const webappAccessUrl = webappUrl(overview?.access?.webappUrl)
|
||||
const cliUrl = overview?.access?.cliUrl
|
||||
const apiUrl = overview?.access?.apiUrl ?? accessConfig?.developerApi?.apiUrl
|
||||
const apiKeysCount = overview?.access?.apiKeyCount ?? accessConfig?.developerApi?.apiKeys?.length ?? 0
|
||||
|
||||
return (
|
||||
@ -145,9 +154,24 @@ const OverviewTab: FC<OverviewTabProps> = ({ instanceId }) => {
|
||||
? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-8 text-center">
|
||||
<span className="i-ri-rocket-line h-5 w-5 text-text-quaternary" />
|
||||
<div className="system-sm-regular text-text-tertiary">{t('overview.notDeployedYet')}</div>
|
||||
<Button size="small" variant="primary" onClick={() => openDeployDrawer({ appInstanceId: app.id })}>
|
||||
{t('overview.deploy')}
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{releaseRows.length === 0
|
||||
? t(canCreateRelease ? 'overview.noReleaseYet' : 'overview.noReleaseSourceUnavailable')
|
||||
: t('overview.notDeployedYet')}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
disabled={releaseRows.length === 0 && !canCreateRelease}
|
||||
onClick={() => {
|
||||
if (releaseRows.length === 0) {
|
||||
switchTab('versions')
|
||||
return
|
||||
}
|
||||
openDeployDrawer({ appInstanceId: app.id })
|
||||
}}
|
||||
>
|
||||
{releaseRows.length === 0 ? t('overview.createRelease') : t('overview.deploy')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@ -195,8 +219,11 @@ const OverviewTab: FC<OverviewTabProps> = ({ instanceId }) => {
|
||||
label={t('overview.api')}
|
||||
enabled={overview?.access?.developerApiEnabled ?? false}
|
||||
hint={overview?.access?.developerApiEnabled
|
||||
? t('overview.apiKeysCount', { count: apiKeysCount })
|
||||
? apiUrl || t('overview.notConfigured')
|
||||
: t('overview.notConfigured')}
|
||||
meta={overview?.access?.developerApiEnabled
|
||||
? t('overview.apiKeysCount', { count: apiKeysCount })
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useCreateDeploymentRelease } from '../hooks/use-deployment-mutations'
|
||||
import {
|
||||
deploymentEnvironmentDeploymentsQueryOptions,
|
||||
deploymentOverviewQueryOptions,
|
||||
deploymentReleaseHistoryQueryOptions,
|
||||
} from '../queries'
|
||||
import {
|
||||
@ -27,8 +32,13 @@ type VersionsTabProps = {
|
||||
|
||||
const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { data: overview } = useQuery(deploymentOverviewQueryOptions(appId))
|
||||
const { data: releaseHistory } = useQuery(deploymentReleaseHistoryQueryOptions(appId))
|
||||
const { data: environmentDeployments } = useQuery(deploymentEnvironmentDeploymentsQueryOptions(appId))
|
||||
const createRelease = useCreateDeploymentRelease()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [releaseName, setReleaseName] = useState('')
|
||||
const [releaseDescription, setReleaseDescription] = useState('')
|
||||
const releaseRows = useMemo(
|
||||
() => releaseHistory?.data?.filter(row => row.id) ?? [],
|
||||
[releaseHistory?.data],
|
||||
@ -37,10 +47,32 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
|
||||
() => deployedRows(environmentDeployments?.data),
|
||||
[environmentDeployments?.data],
|
||||
)
|
||||
const canCreateRelease = overview?.instance?.canCreateRelease ?? true
|
||||
const trimmedReleaseName = releaseName.trim()
|
||||
const canSubmitRelease = Boolean(canCreateRelease && trimmedReleaseName && !createRelease.isPending)
|
||||
|
||||
const handleCreateRelease = async () => {
|
||||
if (!canSubmitRelease)
|
||||
return
|
||||
|
||||
try {
|
||||
await createRelease.mutateAsync({
|
||||
appInstanceId: appId,
|
||||
name: trimmedReleaseName,
|
||||
description: releaseDescription.trim() || undefined,
|
||||
})
|
||||
setReleaseName('')
|
||||
setReleaseDescription('')
|
||||
setIsCreating(false)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('versions.createFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{t('versions.releaseHistory')}
|
||||
{' '}
|
||||
@ -50,12 +82,55 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
disabled={!canCreateRelease}
|
||||
onClick={() => setIsCreating(prev => !prev)}
|
||||
>
|
||||
<span className="i-ri-add-line h-3.5 w-3.5" />
|
||||
{t('versions.createRelease')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!canCreateRelease && (
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
|
||||
{t('versions.sourceAppUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreating && (
|
||||
<div className="rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
|
||||
<div className="mb-3 system-sm-semibold text-text-primary">{t('versions.createRelease')}</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
value={releaseName}
|
||||
onChange={e => setReleaseName(e.target.value)}
|
||||
placeholder={t('versions.releaseNamePlaceholder')}
|
||||
maxLength={128}
|
||||
/>
|
||||
<Input
|
||||
value={releaseDescription}
|
||||
onChange={e => setReleaseDescription(e.target.value)}
|
||||
placeholder={t('versions.releaseDescriptionPlaceholder')}
|
||||
maxLength={512}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="small" variant="secondary" onClick={() => setIsCreating(false)}>
|
||||
{t('versions.cancelCreate')}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={!canSubmitRelease} onClick={() => void handleCreateRelease()}>
|
||||
{createRelease.isPending ? t('versions.creating') : t('versions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releaseRows.length === 0
|
||||
? (
|
||||
<div className="rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
|
||||
{t('versions.empty')}
|
||||
{canCreateRelease ? t('versions.emptyWithCreate') : t('versions.emptySourceUnavailable')}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
|
||||
@ -17,7 +17,6 @@ import { useDeploymentsStore } from '../../store'
|
||||
import {
|
||||
activeRelease,
|
||||
deployedRows,
|
||||
deploymentId,
|
||||
deploymentStatus,
|
||||
environmentId,
|
||||
environmentName,
|
||||
@ -32,7 +31,6 @@ type DeployReleaseMenuProps = {
|
||||
export const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ appInstanceId, releaseId }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
|
||||
const openRollbackModal = useDeploymentsStore(state => state.openRollbackModal)
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: environmentDeployments } = useQuery({
|
||||
...deploymentEnvironmentDeploymentsQueryOptions(appInstanceId),
|
||||
@ -79,15 +77,6 @@ export const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ appInstanceId, r
|
||||
setOpen(false)
|
||||
if (disabled)
|
||||
return
|
||||
if (row) {
|
||||
openRollbackModal({
|
||||
appInstanceId,
|
||||
environmentId: envId,
|
||||
deploymentId: deploymentId(row),
|
||||
targetReleaseId: releaseId,
|
||||
})
|
||||
return
|
||||
}
|
||||
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId })
|
||||
}}
|
||||
>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { DeploymentRuntimeBinding } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query'
|
||||
import type { ConsoleReleaseSummary } from '@/features/deployments/types'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import {
|
||||
@ -11,6 +11,7 @@ import {
|
||||
deploymentInstanceDetailQueryKeys,
|
||||
deploymentInstanceIdentityQueryKeys,
|
||||
deploymentInstanceStateQueryKeys,
|
||||
deploymentOverviewQueryKey,
|
||||
deploymentReleaseHistoryQueryKey,
|
||||
deploymentsListQueryKey,
|
||||
deploymentsListQueryOptions,
|
||||
@ -18,14 +19,19 @@ import {
|
||||
|
||||
export type CreateDeploymentInstanceResult = {
|
||||
appInstanceId: string
|
||||
initialRelease?: ConsoleReleaseSummary
|
||||
}
|
||||
|
||||
type CreateDeploymentParams = {
|
||||
appInstanceId: string
|
||||
environmentId: string
|
||||
releaseId?: string
|
||||
releaseNote?: string
|
||||
releaseId: string
|
||||
bindings: DeploymentRuntimeBinding[]
|
||||
}
|
||||
|
||||
type CreateReleaseParams = {
|
||||
appInstanceId: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type CreateInstanceParams = {
|
||||
@ -113,7 +119,6 @@ export const useCreateDeploymentInstance = () => {
|
||||
|
||||
return {
|
||||
appInstanceId: response.appInstanceId,
|
||||
initialRelease: response.initialRelease,
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -122,6 +127,35 @@ export const useCreateDeploymentInstance = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useCreateDeploymentRelease = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: consoleQuery.enterprise.appDeploy.createRelease.mutationKey(),
|
||||
mutationFn: async ({ appInstanceId, name, description }: CreateReleaseParams) => {
|
||||
const response = await consoleClient.enterprise.appDeploy.createRelease({
|
||||
params: {
|
||||
appInstanceId,
|
||||
},
|
||||
body: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
})
|
||||
if (!response.release?.id)
|
||||
throw new Error('Create release did not return a release.')
|
||||
|
||||
return response.release
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
return invalidateQueries(queryClient, [
|
||||
deploymentReleaseHistoryQueryKey(variables.appInstanceId),
|
||||
deploymentOverviewQueryKey(variables.appInstanceId),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateDeploymentInstance = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@ -151,58 +185,21 @@ export const useStartDeployment = () => {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
releaseId,
|
||||
releaseNote,
|
||||
bindings,
|
||||
}: CreateDeploymentParams) => {
|
||||
let targetReleaseId = releaseId
|
||||
let releaseWasCreated = false
|
||||
await consoleClient.enterprise.appDeploy.previewRelease({
|
||||
if (!releaseId)
|
||||
throw new Error('releaseId is required to start a deployment.')
|
||||
|
||||
return consoleClient.enterprise.appDeploy.createDeployment({
|
||||
params: {
|
||||
appInstanceId,
|
||||
},
|
||||
body: {
|
||||
releaseId: targetReleaseId,
|
||||
environmentId,
|
||||
releaseId,
|
||||
bindings,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
if (!targetReleaseId) {
|
||||
const trimmedReleaseNote = releaseNote?.trim()
|
||||
const response = await consoleClient.enterprise.appDeploy.createRelease({
|
||||
params: {
|
||||
appInstanceId,
|
||||
},
|
||||
body: {
|
||||
name: trimmedReleaseNote || 'Release',
|
||||
description: trimmedReleaseNote || undefined,
|
||||
},
|
||||
})
|
||||
releaseWasCreated = true
|
||||
if (!response.release)
|
||||
throw new Error('Create release did not return a release.')
|
||||
targetReleaseId = response.release.id
|
||||
}
|
||||
|
||||
if (!targetReleaseId)
|
||||
throw new Error('Failed to create a deployable release.')
|
||||
|
||||
return await consoleClient.enterprise.appDeploy.createDeployment({
|
||||
params: {
|
||||
appInstanceId,
|
||||
},
|
||||
body: {
|
||||
environmentId,
|
||||
releaseId: targetReleaseId,
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
if (releaseWasCreated) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: deploymentReleaseHistoryQueryKey(appInstanceId),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
return invalidateDeploymentState(queryClient, variables.appInstanceId)
|
||||
|
||||
@ -23,6 +23,8 @@ export type AppInfo = {
|
||||
description?: string
|
||||
sourceAppId?: string
|
||||
sourceAppName?: string
|
||||
sourceAppAvailable?: boolean
|
||||
canCreateRelease?: boolean
|
||||
}
|
||||
|
||||
export type ConsoleEnvironmentSummary = EnterpriseContract.ConsoleEnvironment & {
|
||||
|
||||
@ -123,7 +123,10 @@ export function toAppInfoFromSummary(summary: AppDeploymentSummary): AppInfo | u
|
||||
mode: (summary.mode || 'workflow') as AppMode,
|
||||
iconType: 'emoji',
|
||||
icon: summary.icon,
|
||||
iconBackground: summary.iconBackground,
|
||||
sourceAppName: summary.sourceAppName,
|
||||
sourceAppAvailable: summary.sourceAppAvailable,
|
||||
canCreateRelease: summary.canCreateRelease,
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,9 +139,13 @@ export function toAppInfoFromOverview(instance?: AppInstanceOverview): AppInfo |
|
||||
name: instance.name ?? instance.id,
|
||||
mode: (instance.mode || 'workflow') as AppMode,
|
||||
iconType: 'emoji',
|
||||
icon: instance.icon,
|
||||
iconBackground: instance.iconBackground,
|
||||
description: instance.description ?? undefined,
|
||||
sourceAppId: instance.sourceAppId,
|
||||
sourceAppName: instance.sourceAppName,
|
||||
sourceAppAvailable: instance.sourceAppAvailable,
|
||||
canCreateRelease: instance.canCreateRelease,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"access.api.disabled": "API access is turned off for this instance.",
|
||||
"access.api.dismissToken": "Dismiss token",
|
||||
"access.api.empty": "Deploy to an environment first to start issuing API keys.",
|
||||
"access.api.endpoint": "Endpoint",
|
||||
"access.api.envPrefix": "env: {{env}}",
|
||||
"access.api.keyList": "API key list",
|
||||
"access.api.newKey": "New key",
|
||||
@ -95,7 +96,6 @@
|
||||
"createModal.appSearchPlaceholder": "Search apps…",
|
||||
"createModal.cancel": "Cancel",
|
||||
"createModal.create": "Create",
|
||||
"createModal.createAndDeploy": "Create and deploy",
|
||||
"createModal.createFailed": "Failed to create app instance.",
|
||||
"createModal.description": "Pick a source app from Studio and create a deployable instance.",
|
||||
"createModal.descriptionLabel": "Description",
|
||||
@ -108,18 +108,25 @@
|
||||
"createModal.sourceApp": "Source app (required)",
|
||||
"createModal.title": "Create app instance",
|
||||
"deployDrawer.bindingsDisabled": "Resolved from the release preview. Editing is not available yet.",
|
||||
"deployDrawer.bindingOptionsFailed": "Failed to load credential options.",
|
||||
"deployDrawer.bindingSelectionHint": "Choose the credentials used by this deployment.",
|
||||
"deployDrawer.cancel": "Cancel",
|
||||
"deployDrawer.defaultSelect": "Select...",
|
||||
"deployDrawer.deploy": "Deploy",
|
||||
"deployDrawer.description": "Create a new release from the current app YAML and deploy it to a target environment.",
|
||||
"deployDrawer.deployFailed": "Failed to start deployment.",
|
||||
"deployDrawer.deploying": "Deploying...",
|
||||
"deployDrawer.description": "Select a release and deploy it to a target environment.",
|
||||
"deployDrawer.envVars": "Environment variables",
|
||||
"deployDrawer.existingReleaseHint": "This existing release will be deployed as-is. No new release will be created.",
|
||||
"deployDrawer.loadingBindings": "Resolving...",
|
||||
"deployDrawer.lockedHint": "Locked to current environment",
|
||||
"deployDrawer.missingRequiredBinding": "Select a credential for this required binding.",
|
||||
"deployDrawer.modelCreds": "Model credentials",
|
||||
"deployDrawer.needsValidation": " (needs validation)",
|
||||
"deployDrawer.newReleaseHint": "A new release will be created from the current app YAML.",
|
||||
"deployDrawer.noBindingRequired": "Not required",
|
||||
"deployDrawer.noCredentialCandidates": "No available credentials.",
|
||||
"deployDrawer.noReleaseAvailable": "Create a release before deploying this app instance.",
|
||||
"deployDrawer.notFound": "Instance not found.",
|
||||
"deployDrawer.noteLabel": "Release note (optional)",
|
||||
"deployDrawer.notePlaceholder": "e.g. Ship onboarding copy tweak",
|
||||
@ -129,9 +136,11 @@
|
||||
"deployDrawer.promoteTitle": "Promote release",
|
||||
"deployDrawer.readOnly": "Read-only",
|
||||
"deployDrawer.releaseLabel": "Release",
|
||||
"deployDrawer.requiredBinding": "Required",
|
||||
"deployDrawer.runtimeCredentials": "Runtime credentials",
|
||||
"deployDrawer.secretPlaceholder": "secret",
|
||||
"deployDrawer.selectEnv": "Select an environment",
|
||||
"deployDrawer.selectCredential": "Select a credential",
|
||||
"deployDrawer.selectProviderCred": "Select {{provider}} credential",
|
||||
"deployDrawer.selectProviderKey": "Select {{provider}} key",
|
||||
"deployDrawer.selectRelease": "Select a release",
|
||||
@ -227,6 +236,9 @@
|
||||
"overview.noAccessConfig": "No access configuration.",
|
||||
"overview.notConfigured": "Not configured",
|
||||
"overview.notDeployedYet": "Not deployed yet.",
|
||||
"overview.noReleaseYet": "Create a release before deploying this app instance.",
|
||||
"overview.noReleaseSourceUnavailable": "The source app was deleted. Existing releases can still be deployed, but there is no release yet.",
|
||||
"overview.createRelease": "Create release",
|
||||
"overview.sourceApp": "Source app",
|
||||
"overview.sourceAppDeletedDescription": "Historical releases are still deployable, but new releases cannot be generated from the deleted source app. Switch to another source app to continue.",
|
||||
"overview.sourceAppDeletedTitle": "Source app was deleted",
|
||||
@ -237,6 +249,16 @@
|
||||
"overview.switchSourceAppHint": "After switching, only newly created releases use the new source app. Historical releases and existing deployments are not changed.",
|
||||
"overview.viewDeployments": "View deployments",
|
||||
"overview.webapp": "WebApp",
|
||||
"versions.cancelCreate": "Cancel",
|
||||
"versions.create": "Create",
|
||||
"versions.createFailed": "Failed to create release.",
|
||||
"versions.createRelease": "Create release",
|
||||
"versions.creating": "Creating...",
|
||||
"versions.emptySourceUnavailable": "No releases yet. The source app was deleted, so new releases cannot be created.",
|
||||
"versions.emptyWithCreate": "No releases yet. Create the first release before deploying.",
|
||||
"versions.releaseDescriptionPlaceholder": "Describe this release",
|
||||
"versions.releaseNamePlaceholder": "Release name",
|
||||
"versions.sourceAppUnavailable": "The source app was deleted. Existing releases are still deployable, but new releases cannot be created.",
|
||||
"rollback.cancel": "Cancel",
|
||||
"rollback.confirm": "Deploy release",
|
||||
"rollback.currentRelease": "Current release",
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"access.api.disabled": "该实例的 API 接入已关闭。",
|
||||
"access.api.dismissToken": "关闭密钥",
|
||||
"access.api.empty": "请先部署到环境后再签发 API 密钥。",
|
||||
"access.api.endpoint": "请求地址",
|
||||
"access.api.envPrefix": "env:{{env}}",
|
||||
"access.api.keyList": "API Key 列表",
|
||||
"access.api.newKey": "生成新 Key",
|
||||
@ -95,7 +96,6 @@
|
||||
"createModal.appSearchPlaceholder": "搜索应用…",
|
||||
"createModal.cancel": "取消",
|
||||
"createModal.create": "创建",
|
||||
"createModal.createAndDeploy": "创建并部署",
|
||||
"createModal.createFailed": "创建应用实例失败。",
|
||||
"createModal.description": "从 Studio 选择一个源应用并创建可部署的实例。",
|
||||
"createModal.descriptionLabel": "描述",
|
||||
@ -108,18 +108,25 @@
|
||||
"createModal.sourceApp": "源应用(必选)",
|
||||
"createModal.title": "创建应用实例",
|
||||
"deployDrawer.bindingsDisabled": "来自发布预览的解析结果,暂不支持在这里编辑。",
|
||||
"deployDrawer.bindingOptionsFailed": "加载凭据选项失败。",
|
||||
"deployDrawer.bindingSelectionHint": "选择本次部署要使用的运行时凭据。",
|
||||
"deployDrawer.cancel": "取消",
|
||||
"deployDrawer.defaultSelect": "选择...",
|
||||
"deployDrawer.deploy": "部署",
|
||||
"deployDrawer.description": "基于当前应用 YAML 创建一个新的发布版本,并部署到目标环境。",
|
||||
"deployDrawer.deployFailed": "启动部署失败。",
|
||||
"deployDrawer.deploying": "部署中...",
|
||||
"deployDrawer.description": "选择一个发布版本,并部署到目标环境。",
|
||||
"deployDrawer.envVars": "环境变量",
|
||||
"deployDrawer.existingReleaseHint": "将直接部署该已有发布版本,不会创建新的版本。",
|
||||
"deployDrawer.loadingBindings": "解析中...",
|
||||
"deployDrawer.lockedHint": "已锁定至当前环境",
|
||||
"deployDrawer.missingRequiredBinding": "请选择该必填绑定使用的凭据。",
|
||||
"deployDrawer.modelCreds": "模型凭据",
|
||||
"deployDrawer.needsValidation": "(待验证)",
|
||||
"deployDrawer.newReleaseHint": "将基于当前应用 YAML 创建一个新的发布版本。",
|
||||
"deployDrawer.noBindingRequired": "无需配置",
|
||||
"deployDrawer.noCredentialCandidates": "没有可用凭据。",
|
||||
"deployDrawer.noReleaseAvailable": "请先创建发布版本,再部署该应用实例。",
|
||||
"deployDrawer.notFound": "未找到实例。",
|
||||
"deployDrawer.noteLabel": "发布备注(可选)",
|
||||
"deployDrawer.notePlaceholder": "例如:优化引导文案",
|
||||
@ -129,9 +136,11 @@
|
||||
"deployDrawer.promoteTitle": "推送发布版本",
|
||||
"deployDrawer.readOnly": "只读",
|
||||
"deployDrawer.releaseLabel": "发布版本",
|
||||
"deployDrawer.requiredBinding": "必填",
|
||||
"deployDrawer.runtimeCredentials": "运行时凭据",
|
||||
"deployDrawer.secretPlaceholder": "机密值",
|
||||
"deployDrawer.selectEnv": "选择一个环境",
|
||||
"deployDrawer.selectCredential": "选择凭据",
|
||||
"deployDrawer.selectProviderCred": "选择 {{provider}} 凭据",
|
||||
"deployDrawer.selectProviderKey": "选择 {{provider}} 密钥",
|
||||
"deployDrawer.selectRelease": "选择一个发布版本",
|
||||
@ -227,6 +236,9 @@
|
||||
"overview.noAccessConfig": "未配置接入方式。",
|
||||
"overview.notConfigured": "未配置",
|
||||
"overview.notDeployedYet": "尚未部署。",
|
||||
"overview.noReleaseYet": "请先创建发布版本,再部署该应用实例。",
|
||||
"overview.noReleaseSourceUnavailable": "源应用已删除。已有发布版本仍可部署,但当前还没有可部署版本。",
|
||||
"overview.createRelease": "创建版本",
|
||||
"overview.sourceApp": "源应用",
|
||||
"overview.sourceAppDeletedDescription": "历史 Release 仍可继续部署,但无法再基于已删除的源应用生成新 Release。请切换到其他源应用后继续使用。",
|
||||
"overview.sourceAppDeletedTitle": "源应用已被删除",
|
||||
@ -237,6 +249,16 @@
|
||||
"overview.switchSourceAppHint": "切换后,仅新建 Release 会使用新的源应用;历史 Release 和现有部署不受影响。",
|
||||
"overview.viewDeployments": "查看部署",
|
||||
"overview.webapp": "WebApp",
|
||||
"versions.cancelCreate": "取消",
|
||||
"versions.create": "创建",
|
||||
"versions.createFailed": "创建发布版本失败。",
|
||||
"versions.createRelease": "创建版本",
|
||||
"versions.creating": "创建中...",
|
||||
"versions.emptySourceUnavailable": "暂无发布版本。源应用已删除,无法创建新版本。",
|
||||
"versions.emptyWithCreate": "暂无发布版本,请先创建第一个可部署版本。",
|
||||
"versions.releaseDescriptionPlaceholder": "描述这个版本",
|
||||
"versions.releaseNamePlaceholder": "版本名称",
|
||||
"versions.sourceAppUnavailable": "源应用已删除。已有发布版本仍可部署,但无法创建新版本。",
|
||||
"rollback.cancel": "取消",
|
||||
"rollback.confirm": "确认部署",
|
||||
"rollback.currentRelease": "当前发布",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user