feat(app-deploy): wire release deployment UI

This commit is contained in:
zhangx1n 2026-05-04 15:34:22 +08:00
parent b305e8b65d
commit 141d936e91
19 changed files with 575 additions and 192 deletions

View File

@ -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,

View File

@ -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'
}

View File

@ -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
*/

View File

@ -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>
)

View File

@ -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'))
}
}}
/>
)}

View File

@ -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>

View File

@ -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',
)}
>

View File

@ -84,6 +84,7 @@ const RollbackModal: FC = () => {
appInstanceId: modal.appInstanceId,
environmentId: modal.environmentId,
releaseId: modal.targetReleaseId,
bindings: [],
})
}

View File

@ -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)}
/>

View File

@ -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)

View File

@ -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!)}
/>
)

View File

@ -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>

View File

@ -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>
)
: (

View File

@ -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 })
}}
>

View File

@ -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)

View File

@ -23,6 +23,8 @@ export type AppInfo = {
description?: string
sourceAppId?: string
sourceAppName?: string
sourceAppAvailable?: boolean
canCreateRelease?: boolean
}
export type ConsoleEnvironmentSummary = EnterpriseContract.ConsoleEnvironment & {

View File

@ -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,
}
}

View File

@ -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",

View File

@ -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": "当前发布",