use real api

This commit is contained in:
Stephen Zhou 2026-04-28 12:28:44 +08:00
parent fb4c111aec
commit bea78ade6e
No known key found for this signature in database
25 changed files with 2043 additions and 1837 deletions

View File

@ -0,0 +1,107 @@
import type { AccessPermissionKind } from './types'
import type {
ConsoleEnvironmentSummary,
ConsoleReleaseSummary,
EnvironmentDeploymentRow,
EnvironmentOption,
} from '@/contract/console/deployments'
import { PUBLIC_API_PREFIX } from '@/config'
export type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed'
export const formatDate = (value?: string) => {
if (!value)
return '—'
return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16)
}
export const environmentId = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => environment?.id ?? ''
export const environmentName = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => environment?.name || environment?.id || '—'
export const environmentMode = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => {
const type = environment?.type?.toLowerCase() ?? ''
return type.includes('isolated') ? 'isolated' : 'shared'
}
export const environmentBackend = (environment?: ConsoleEnvironmentSummary) => {
const runtime = environment?.runtime?.toLowerCase() ?? ''
return runtime.includes('host') ? 'host' : 'k8s'
}
export const environmentHealth = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => {
const status = environment?.status?.toLowerCase() ?? ''
return status.includes('ready') ? 'ready' : 'degraded'
}
export const releaseId = (release?: ConsoleReleaseSummary) => release?.id ?? ''
export const releaseLabel = (release?: ConsoleReleaseSummary) => release?.displayId || release?.id || '—'
export const releaseCommit = (release?: ConsoleReleaseSummary) => release?.commitId || '—'
const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:\/\//i
const withLeadingSlash = (path: string) => path.startsWith('/') ? path : `/${path}`
const publicWebappOrigin = () => {
try {
return new URL(PUBLIC_API_PREFIX).origin
}
catch {
return PUBLIC_API_PREFIX.replace(/\/api\/?$/, '').replace(/\/+$/, '')
}
}
export const webappUrl = (url?: string) => {
if (!url)
return ''
if (absoluteUrlRegExp.test(url))
return url
const origin = publicWebappOrigin()
return `${origin}${withLeadingSlash(url)}`
}
export const deploymentId = (row?: EnvironmentDeploymentRow) =>
row?.pendingDeployment?.deploymentId || row?.instance?.currentDeploymentId || ''
export const activeRelease = (row?: EnvironmentDeploymentRow) => row?.observedRuntime?.release
export const targetRelease = (row?: EnvironmentDeploymentRow) => row?.pendingDeployment?.release
export const failedReleaseId = (row?: EnvironmentDeploymentRow) => row?.instance?.lastError?.releaseId
export const deploymentStatus = (row: EnvironmentDeploymentRow): DeploymentUiStatus => {
if (row.pendingDeployment)
return 'deploying'
if (row.instance?.lastError)
return 'deploy_failed'
const status = row.instance?.status?.toLowerCase() ?? ''
if (status.includes('deploying') || status.includes('pending'))
return 'deploying'
if (status.includes('fail') || status.includes('error'))
return 'deploy_failed'
return 'ready'
}
export const deployedRows = (rows?: EnvironmentDeploymentRow[]) =>
rows?.filter(row => row.environment?.id && (row.instance || row.observedRuntime || row.pendingDeployment)) ?? []
export const accessModeToPermissionKey = (mode?: string): AccessPermissionKind => {
const normalized = mode?.toLowerCase() ?? ''
if (normalized === 'private')
return 'specific'
if (normalized === 'public')
return 'anyone'
return 'organization'
}
export const permissionKeyToAccessMode = (key: AccessPermissionKind) => {
if (key === 'organization')
return 'private_all'
if (key === 'specific')
return 'private'
return 'public'
}

View File

@ -207,7 +207,7 @@ const CreateInstanceForm: FC<{ onClose: () => void }> = ({ onClose }) => {
description: description.trim() || undefined,
})
if (thenDeploy) {
openDeployDrawer({ instanceId })
openDeployDrawer({ appId: instanceId })
return
}
router.push(`/deployments/${instanceId}/overview`)

View File

@ -1,51 +1,109 @@
'use client'
import type { FC } from 'react'
import type { CredentialBinding, Deployment, Environment, EnvVariable, Instance, Release } from './types'
import type { BindingsProto, ConsoleReleaseSummary, DeploymentSlot, EnvironmentOption } from '@/contract/console/deployments'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { skipToken, useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { mockCredentials } from './mock-data'
import { consoleQuery } from '@/service/client'
import { environmentHealth, environmentMode, environmentName, releaseCommit, releaseLabel } from './api-utils'
import { HealthBadge, ModeBadge } from './status-badge'
import { useDeploymentsStore } from './store'
type RequiredBindings = {
model: string[]
plugin: string[]
envVars: { key: string, type: 'string' | 'secret' }[]
type CredentialRequirement = {
slot: string
label: string
required: boolean
selectedCredentialId?: string
options: { id: string, label: string }[]
}
function deriveRequiredBindings(appId: string): RequiredBindings {
switch (appId) {
case 'app-payments-workflow':
return {
model: ['OpenAI', 'DeepSeek'],
plugin: ['Gmail', 'Notion'],
envVars: [
{ key: 'kn', type: 'string' },
{ key: 'dbkey', type: 'secret' },
],
}
case 'app-customer-support':
return {
model: ['OpenAI'],
plugin: ['Gmail'],
envVars: [
{ key: 'dbkey', type: 'secret' },
{ key: 'keyno', type: 'string' },
],
}
default:
return {
model: ['OpenAI'],
plugin: [],
envVars: [],
}
type EnvVarRequirement = {
key: string
label: string
required: boolean
selectedEnvVarId?: string
type: 'string' | 'secret'
options: { id: string, label: string }[]
}
type RequiredBindings = {
model: CredentialRequirement[]
plugin: CredentialRequirement[]
envVars: EnvVarRequirement[]
}
function isModelSlot(kind?: string) {
return kind?.toLowerCase().includes('model')
}
function isEnvVarSlot(kind?: string) {
const normalized = kind?.toLowerCase() ?? ''
return normalized.includes('env')
}
function isSecretValue(type?: string) {
return type?.toLowerCase().includes('secret') ?? false
}
function deriveRequiredBindings(slots: DeploymentSlot[] | undefined): RequiredBindings {
const required: RequiredBindings = {
model: [],
plugin: [],
envVars: [],
}
slots?.forEach((slot) => {
const slotName = slot.slot || slot.label
if (!slotName)
return
if (isEnvVarSlot(slot.kind)) {
required.envVars.push({
key: slotName,
label: slot.label || slotName,
required: slot.required ?? true,
selectedEnvVarId: slot.selectedEnvVarId,
type: isSecretValue(slot.envVarOptions?.[0]?.valueType) ? 'secret' : 'string',
options: slot.envVarOptions
?.filter(option => option.id)
.map(option => ({
id: option.id!,
label: `${option.name || option.id}${option.maskedValue ? ` · ${option.maskedValue}` : ''}`,
})) ?? [],
})
return
}
const target = isModelSlot(slot.kind) ? required.model : required.plugin
target.push({
slot: slotName,
label: slot.label || slotName,
required: slot.required ?? true,
selectedCredentialId: slot.selectedCredentialId,
options: slot.credentialOptions
?.filter(option => option.id)
.map(option => ({
id: option.id!,
label: option.displayName || option.provider || option.id!,
})) ?? [],
})
})
return required
}
function credentialValue(values: Record<string, string>, item: CredentialRequirement) {
return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || ''
}
function envVarValue(values: Record<string, string>, item: EnvVarRequirement) {
return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || ''
}
type FieldProps = {
@ -121,24 +179,24 @@ const LabeledSelect: FC<LabeledSelectProps> = ({ label, ...rest }) => (
</div>
)
type EnvironmentRowProps = { env: Environment }
type EnvironmentRowProps = { env: EnvironmentOption }
const EnvironmentRow: FC<EnvironmentRowProps> = ({ env }) => (
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex items-center gap-2">
<span className="system-sm-semibold text-text-primary">{env.name}</span>
<ModeBadge mode={env.mode} />
<HealthBadge health={env.health} />
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
<ModeBadge mode={environmentMode(env)} />
<HealthBadge health={environmentHealth(env)} />
</div>
<span className="system-xs-regular text-text-tertiary uppercase">{env.backend}</span>
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
</div>
)
type DeployFormProps = {
instance: Instance
environments: Environment[]
releases: Release[]
deployments: Deployment[]
appId: string
environments: EnvironmentOption[]
releases: ConsoleReleaseSummary[]
defaultReleaseId?: string
lockedEnvId?: string
presetReleaseId?: string
onCancel: () => void
@ -146,84 +204,56 @@ type DeployFormProps = {
environmentId: string
releaseId?: string
releaseNote?: string
credentials: CredentialBinding[]
envVariables: EnvVariable[]
bindings?: BindingsProto
}) => void
}
const DeployForm: FC<DeployFormProps> = ({
instance,
appId,
environments,
releases,
deployments,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
onCancel,
onSubmit,
}) => {
const { t } = useTranslation('deployments')
const bindingProfileId = instance.bindingProfileId ?? instance.appId
const required = useMemo(() => deriveRequiredBindings(bindingProfileId), [bindingProfileId])
const credentialsByProvider = useMemo(() => {
const map = new Map<string, typeof mockCredentials>()
mockCredentials.forEach((c) => {
const list = map.get(c.provider) ?? []
list.push(c)
map.set(c.provider, list)
})
return map
}, [])
const presetRelease = useMemo(
() => presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined,
[releases, presetReleaseId],
)
const isPromote = Boolean(presetRelease)
const existingDeployment = useMemo(
() => lockedEnvId
? deployments.find(d => d.instanceId === instance.id && d.environmentId === lockedEnvId)
: undefined,
[deployments, instance.id, lockedEnvId],
)
const [selectedEnvId, setSelectedEnvId] = useState<string>(
() => lockedEnvId ?? environments[0]?.id ?? '',
)
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
const planReleaseId = presetRelease?.id ?? defaultReleaseId ?? releases[0]?.id
const deploymentPlan = useQuery(consoleQuery.deployments.deploymentPlan.queryOptions({
input: selectedEnvironmentId && planReleaseId
? {
params: {
appId,
environmentId: selectedEnvironmentId,
releaseId: planReleaseId,
},
}
: skipToken,
}))
const required = useMemo(() => deriveRequiredBindings(deploymentPlan.data?.slots), [deploymentPlan.data?.slots])
const [releaseNote, setReleaseNote] = useState<string>('')
const [modelCredentials, setModelCredentials] = useState<Record<string, string>>(() => {
const model: Record<string, string> = {}
required.model.forEach((provider) => {
const existing = existingDeployment?.credentials.find(c => c.kind === 'model' && c.provider === provider)
const first = credentialsByProvider.get(provider)?.[0]
model[provider] = existing?.credentialId ?? first?.id ?? ''
})
return model
})
const [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>(() => {
const plugin: Record<string, string> = {}
required.plugin.forEach((provider) => {
const existing = existingDeployment?.credentials.find(c => c.kind === 'plugin' && c.provider === provider)
const first = credentialsByProvider.get(provider)?.[0]
plugin[provider] = existing?.credentialId ?? first?.id ?? ''
})
return plugin
})
const [envValues, setEnvValues] = useState<Record<string, string>>(() => {
const env: Record<string, string> = {}
required.envVars.forEach((v) => {
const existing = existingDeployment?.envVariables.find(e => e.key === v.key)
env[v.key] = existing?.value ?? ''
})
return env
})
const [modelCredentials, setModelCredentials] = useState<Record<string, string>>({})
const [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>({})
const [envValues, setEnvValues] = useState<Record<string, string>>({})
const canDeploy = Boolean(
selectedEnvId
&& required.model.every(p => modelCredentials[p])
&& required.plugin.every(p => pluginCredentials[p])
&& required.envVars.every(v => envValues[v.key]?.length),
selectedEnvironmentId
&& deploymentPlan.data?.canDeploy !== false
&& !deploymentPlan.isFetching
&& required.model.every(item => !item.required || credentialValue(modelCredentials, item))
&& required.plugin.every(item => !item.required || credentialValue(pluginCredentials, item))
&& required.envVars.every(item => !item.required || envVarValue(envValues, item)),
)
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
@ -231,29 +261,25 @@ const DeployForm: FC<DeployFormProps> = ({
const handleDeploy = () => {
if (!canDeploy)
return
const credentials: CredentialBinding[] = [
...required.model.map<CredentialBinding>(provider => ({
provider,
kind: 'model',
credentialId: modelCredentials[provider],
const bindings: BindingsProto = {
models: required.model.map(item => ({
slot: item.slot,
credentialId: credentialValue(modelCredentials, item),
})),
...required.plugin.map<CredentialBinding>(provider => ({
provider,
kind: 'plugin',
credentialId: pluginCredentials[provider],
plugins: required.plugin.map(item => ({
slot: item.slot,
credentialId: credentialValue(pluginCredentials, item),
})),
]
const envVariables: EnvVariable[] = required.envVars.map<EnvVariable>(v => ({
key: v.key,
value: envValues[v.key] ?? '',
type: v.type,
}))
envVars: required.envVars.map(item => ({
slot: item.key,
envVarId: envVarValue(envValues, item),
})),
}
onSubmit({
environmentId: selectedEnvId,
environmentId: selectedEnvironmentId,
releaseId: presetRelease?.id,
releaseNote: isPromote ? undefined : releaseNote,
credentials,
envVariables,
bindings,
})
}
@ -274,9 +300,9 @@ const DeployForm: FC<DeployFormProps> = ({
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{presetRelease.id}</span>
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(presetRelease)}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{presetRelease.gateCommitId}</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(presetRelease)}</span>
{presetRelease.description && (
<>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
@ -314,11 +340,11 @@ const DeployForm: FC<DeployFormProps> = ({
? <EnvironmentRow env={lockedEnv} />
: (
<DeploymentSelect
value={selectedEnvId}
value={selectedEnvironmentId}
onChange={setSelectedEnvId}
options={environments.map(env => ({
value: env.id,
label: `${env.name} · ${t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${env.backend.toUpperCase()}`,
options={environments.filter(env => env.id).map(env => ({
value: env.id!,
label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${(env.type ?? 'env').toUpperCase()}`,
}))}
placeholder={t('deployDrawer.selectEnv')}
/>
@ -331,19 +357,18 @@ const DeployForm: FC<DeployFormProps> = ({
{required.model.length > 0 && (
<Field label={t('deployDrawer.modelCreds')}>
<div className="flex flex-col gap-2">
{required.model.map((provider) => {
const providerCreds = credentialsByProvider.get(provider) ?? []
{required.model.map((item) => {
return (
<LabeledSelect
key={provider}
label={provider}
value={modelCredentials[provider] ?? ''}
onChange={v => setModelCredentials(prev => ({ ...prev, [provider]: v }))}
options={providerCreds.map(c => ({
value: c.id,
label: `${c.name}${c.validated ? '' : t('deployDrawer.needsValidation')}`,
key={item.slot}
label={item.label}
value={credentialValue(modelCredentials, item)}
onChange={v => setModelCredentials(prev => ({ ...prev, [item.slot]: v }))}
options={item.options.map(option => ({
value: option.id,
label: option.label,
}))}
placeholder={t('deployDrawer.selectProviderKey', { provider })}
placeholder={t('deployDrawer.selectProviderKey', { provider: item.label })}
/>
)
})}
@ -354,16 +379,15 @@ const DeployForm: FC<DeployFormProps> = ({
{required.plugin.length > 0 && (
<Field label={t('deployDrawer.pluginCreds')}>
<div className="flex flex-col gap-2">
{required.plugin.map((provider) => {
const providerCreds = credentialsByProvider.get(provider) ?? []
{required.plugin.map((item) => {
return (
<LabeledSelect
key={provider}
label={provider}
value={pluginCredentials[provider] ?? ''}
onChange={v => setPluginCredentials(prev => ({ ...prev, [provider]: v }))}
options={providerCreds.map(c => ({ value: c.id, label: c.name }))}
placeholder={t('deployDrawer.selectProviderCred', { provider })}
key={item.slot}
label={item.label}
value={credentialValue(pluginCredentials, item)}
onChange={v => setPluginCredentials(prev => ({ ...prev, [item.slot]: v }))}
options={item.options.map(option => ({ value: option.id, label: option.label }))}
placeholder={t('deployDrawer.selectProviderCred', { provider: item.label })}
/>
)
})}
@ -378,18 +402,14 @@ const DeployForm: FC<DeployFormProps> = ({
<div className="flex flex-col gap-2">
{required.envVars.map(v => (
<div key={v.key} className="flex items-center gap-2">
<span className="w-16 shrink-0 system-xs-medium text-text-secondary">{v.key}</span>
<div className="flex h-8 min-w-0 flex-1 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
<input
type={v.type === 'secret' ? 'password' : 'text'}
value={envValues[v.key] ?? ''}
placeholder={v.type === 'secret' ? t('deployDrawer.secretPlaceholder') : t('deployDrawer.valuePlaceholder')}
onChange={e => setEnvValues(prev => ({ ...prev, [v.key]: e.target.value }))}
className={cn('min-w-0 flex-1 bg-transparent text-[13px] text-text-secondary outline-hidden placeholder:text-text-quaternary')}
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{v.label}</span>
<div className="min-w-0 flex-1">
<DeploymentSelect
value={envVarValue(envValues, v)}
onChange={next => setEnvValues(prev => ({ ...prev, [v.key]: next }))}
options={v.options.map(option => ({ value: option.id, label: option.label }))}
placeholder={t('deployDrawer.defaultSelect')}
/>
<span className="shrink-0 rounded-md border border-divider-subtle px-1.5 text-[10px] font-medium text-text-tertiary uppercase">
{v.type}
</span>
</div>
</div>
))}
@ -412,16 +432,15 @@ const DeployForm: FC<DeployFormProps> = ({
const DeployDrawer: FC = () => {
const { t } = useTranslation('deployments')
const drawer = useDeploymentsStore(state => state.deployDrawer)
const environments = useDeploymentsStore(state => state.environments)
const instances = useDeploymentsStore(state => state.instances)
const releases = useDeploymentsStore(state => state.releases)
const deployments = useDeploymentsStore(state => state.deployments)
const appData = useDeploymentsStore(state => drawer.appId ? state.appData[drawer.appId] : undefined)
const closeDeployDrawer = useDeploymentsStore(state => state.closeDeployDrawer)
const startDeploy = useDeploymentsStore(state => state.startDeploy)
const open = drawer.open
const instance = instances.find(i => i.id === drawer.instanceId)
const formKey = `${drawer.instanceId ?? 'none'}-${drawer.environmentId ?? 'any'}-${drawer.releaseId ?? 'new'}-${open ? '1' : '0'}`
const environments = appData?.candidates.environmentOptions ?? []
const releases = appData?.candidates.releases ?? []
const defaultReleaseId = appData?.candidates.defaultReleaseId
const formKey = `${drawer.appId ?? 'none'}-${drawer.environmentId ?? 'any'}-${drawer.releaseId ?? 'new'}-${open ? '1' : '0'}`
return (
<Dialog
@ -430,26 +449,25 @@ const DeployDrawer: FC = () => {
>
<DialogContent className="w-[560px] max-w-[90vw]">
<DialogCloseButton />
{!instance
{!drawer.appId
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (
<DeployForm
key={formKey}
instance={instance}
appId={drawer.appId}
environments={environments}
releases={releases}
deployments={deployments}
defaultReleaseId={defaultReleaseId}
lockedEnvId={drawer.environmentId}
presetReleaseId={drawer.releaseId}
onCancel={closeDeployDrawer}
onSubmit={({ environmentId, releaseId, releaseNote, credentials, envVariables }) =>
onSubmit={({ environmentId, releaseId, releaseNote, bindings }) =>
startDeploy({
instanceId: instance.id,
appId: drawer.appId!,
environmentId,
releaseId,
releaseNote,
credentials,
envVariables,
bindings,
})}
/>
)}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { AppInfo, Deployment, DeployStatus, Environment, Instance } from './types'
import type { AppInfo } from './types'
import type { DeploymentAppData } from '@/service/deployments'
import type { AppModeEnum } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -21,6 +22,7 @@ import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useRouter } from '@/next/navigation'
import { deployedRows, deploymentStatus, environmentId, environmentName, releaseLabel } from './api-utils'
import CreateInstanceModal from './create-instance-modal'
import DeployDrawer from './deploy-drawer'
import RollbackModal from './rollback-modal'
@ -94,13 +96,11 @@ const NewInstanceCard: FC<NewInstanceCardProps> = ({ onOpen }) => {
}
type InstanceCardProps = {
instance: Instance
app: AppInfo
deployments: Deployment[]
environments: Environment[]
appData?: DeploymentAppData
}
const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, environments }) => {
const InstanceCard: FC<InstanceCardProps> = ({ app, appData }) => {
const { t } = useTranslation('deployments')
const router = useRouter()
const { formatTimeFromNow } = useFormatTimeFromNow()
@ -108,7 +108,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const deleteInstance = useDeploymentsStore(state => state.deleteInstance)
const navigateToDetail = () => router.push(`/deployments/${instance.id}/overview`)
const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`)
const handleMenuAction = (e: React.MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
@ -117,17 +117,20 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
action()
}
const deployments = useMemo(
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
[appData?.environmentDeployments.environmentDeployments],
)
const envCount = deployments.length
const failedCount = deployments.filter(d => d.status === 'deploy_failed').length
const deployingCount = deployments.filter(d => d.status === 'deploying').length
const readyCount = deployments.filter(d => d.status === 'ready').length
const envMap = useMemo(() => new Map(environments.map(env => [env.id, env])), [environments])
const failedCount = deployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
const deployingCount = deployments.filter(row => deploymentStatus(row) === 'deploying').length
const readyCount = deployments.filter(row => deploymentStatus(row) === 'ready').length
const lastDeployedAt = useMemo(() => {
if (deployments.length === 0)
return null
return deployments.reduce((latest, d) => {
const t = new Date(d.createdAt).getTime()
return deployments.reduce((latest, row) => {
const t = new Date(row.instance?.lastDeployedAt || row.instance?.lastReadyAt || '').getTime()
return t > latest ? t : latest
}, 0)
}, [deployments])
@ -154,7 +157,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0)
secondaryParts.push(t('card.ready', { count: readyCount }))
const statusLabel = (status: DeployStatus) => {
const statusLabel = (status: ReturnType<typeof deploymentStatus>) => {
if (status === 'deploy_failed')
return t('status.deployFailed')
return t(`status.${status}`)
@ -166,16 +169,16 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
<div className="flex min-w-[220px] flex-col gap-1">
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
{deployments.map((deployment) => {
const env = envMap.get(deployment.environmentId)
const status = deploymentStatus(deployment)
return (
<div key={deployment.id} className="flex min-w-0 items-center justify-between gap-3">
<div key={environmentId(deployment.environment)} className="flex min-w-0 items-center justify-between gap-3">
<span className="min-w-0 truncate text-text-tertiary">
{env?.name ?? deployment.environmentId}
{environmentName(deployment.environment)}
</span>
<span className="shrink-0 text-text-secondary">
{statusLabel(deployment.status)}
{statusLabel(status)}
{' · '}
{deployment.activeReleaseId}
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
</span>
</div>
)
@ -226,7 +229,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
</div>
<div className="w-0 grow py-px">
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
<div className="truncate" title={instance.name}>{instance.name}</div>
<div className="truncate" title={app.name}>{app.name}</div>
</div>
<div className="truncate text-[10px] leading-[18px] font-medium text-text-tertiary" title={appModeLabel}>
{appModeLabel}
@ -299,7 +302,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[216px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={e => handleMenuAction(e, () => openDeployDrawer({ instanceId: instance.id }))}
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))}
>
<span className="system-sm-regular text-text-secondary">{t('card.menu.deploy')}</span>
</DropdownMenuItem>
@ -312,7 +315,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 px-3"
onClick={e => handleMenuAction(e, () => deleteInstance(instance.id))}
onClick={e => handleMenuAction(e, () => deleteInstance(app.id))}
>
<span className="system-sm-regular text-text-destructive">{t('card.menu.delete')}</span>
</DropdownMenuItem>
@ -391,9 +394,8 @@ const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ value, options, onChang
const DeploymentsMain: FC = () => {
const { t } = useTranslation('deployments')
const instances = useDeploymentsStore(state => state.instances)
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const sourceApps = useDeploymentsStore(state => state.sourceApps)
const appData = useDeploymentsStore(state => state.appData)
const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal)
const [envFilter, setEnvFilter] = useQueryState(
@ -416,15 +418,28 @@ const DeploymentsMain: FC = () => {
}
const { appMap } = useSourceApps()
const deploymentsByInstance = useMemo(() => {
const map = new Map<string, Deployment[]>()
deployments.forEach((d) => {
const list = map.get(d.instanceId) ?? []
list.push(d)
map.set(d.instanceId, list)
const apps = useMemo(
() => sourceApps.length > 0 ? sourceApps : [...appMap.values()],
[appMap, sourceApps],
)
const appDataList = useMemo(() => Object.values(appData), [appData])
const environments = useMemo(() => {
const map = new Map<string, string>()
appDataList.forEach((data) => {
data.candidates.environmentOptions?.forEach((env) => {
const id = environmentId(env)
if (id)
map.set(id, environmentName(env))
})
data.environmentDeployments.environmentDeployments?.forEach((row) => {
const id = environmentId(row.environment)
if (id)
map.set(id, environmentName(row.environment))
})
})
return map
}, [deployments])
return [...map.entries()].map(([id, name]) => ({ id, name }))
}, [appDataList])
const envIdSet = useMemo(() => new Set(environments.map(e => e.id)), [environments])
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed' || envIdSet.has(envFilter)
@ -453,23 +468,21 @@ const DeploymentsMain: FC = () => {
const visibleInstances = useMemo(() => {
const byEnv = activeFilter === 'all'
? instances
? apps
: activeFilter === 'not-deployed'
? instances.filter(i => (deploymentsByInstance.get(i.id)?.length ?? 0) === 0)
: instances.filter(i => (deploymentsByInstance.get(i.id) ?? []).some(d => d.environmentId === activeFilter))
? apps.filter(app => deployedRows(appData[app.id]?.environmentDeployments.environmentDeployments).length === 0)
: apps.filter(app => deployedRows(appData[app.id]?.environmentDeployments.environmentDeployments).some(row => environmentId(row.environment) === activeFilter))
const q = keywords.trim().toLowerCase()
if (!q)
return byEnv
return byEnv.filter((i) => {
const app = appMap.get(i.appId)
return byEnv.filter((app) => {
return (
i.name.toLowerCase().includes(q)
|| (i.description ?? '').toLowerCase().includes(q)
|| (app?.name.toLowerCase().includes(q) ?? false)
app.name.toLowerCase().includes(q)
|| (app.description ?? '').toLowerCase().includes(q)
)
})
}, [instances, deploymentsByInstance, activeFilter, keywords, appMap])
}, [apps, activeFilter, keywords, appData])
return (
<>
@ -494,17 +507,12 @@ const DeploymentsMain: FC = () => {
</div>
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<NewInstanceCard onOpen={openCreateInstanceModal} />
{visibleInstances.map((instance) => {
const app = appMap.get(instance.appId)
if (!app)
return null
{visibleInstances.map((app) => {
return (
<InstanceCard
key={instance.id}
instance={instance}
key={app.id}
app={app}
deployments={deploymentsByInstance.get(instance.id) ?? []}
environments={environments}
appData={appData[app.id]}
/>
)
})}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { AccessPermissionKind, EnvAccessPermission, Environment } from '../types'
import type { AccessPermissionKind } from '../types'
import type { ConsoleEnvironmentSummary, DeveloperAPIKeySummary } from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -12,6 +13,13 @@ import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
accessModeToPermissionKey,
deployedRows,
environmentName,
permissionKeyToAccessMode,
webappUrl,
} from '../api-utils'
import { useDeploymentsStore } from '../store'
type SectionProps = {
@ -87,22 +95,18 @@ const CopyPill: FC<CopyPillProps> = ({ label, value, prefix, className }) => {
}
type ApiKeyRowProps = {
label: string
envName: string
value: string
apiKey: DeveloperAPIKeySummary
onRevoke: () => void
}
const ApiKeyRow: FC<ApiKeyRowProps> = ({ label, envName, value, onRevoke }) => {
const ApiKeyRow: FC<ApiKeyRowProps> = ({ apiKey, onRevoke }) => {
const { t } = useTranslation('deployments')
const [visible, setVisible] = useState(false)
const [copied, setCopied] = useState(false)
const displayValue = visible ? value : `${value.slice(0, 6)}${'•'.repeat(14)}${value.slice(-4)}`
const displayValue = apiKey.maskedPrefix || apiKey.id || '—'
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value)
await navigator.clipboard.writeText(displayValue)
setCopied(true)
toast.success(t('access.copyToast'))
window.setTimeout(() => setCopied(false), 1500)
@ -115,23 +119,15 @@ const ApiKeyRow: FC<ApiKeyRowProps> = ({ label, envName, value, onRevoke }) => {
return (
<div className="flex items-center gap-3 py-1.5">
<div className="flex min-w-[140px] flex-col">
<span className="system-sm-medium text-text-primary">{label}</span>
<span className="system-sm-medium text-text-primary">{apiKey.name || apiKey.id}</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.envPrefix', { env: envName })}
{t('access.api.envPrefix', { env: apiKey.environmentName || apiKey.environmentId })}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
<div className="min-w-0 flex-1 truncate font-mono text-[13px] font-medium text-text-secondary">
{displayValue}
</div>
<button
type="button"
onClick={() => setVisible(prev => !prev)}
aria-label={visible ? t('access.hide') : t('access.show')}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className={cn(visible ? 'i-ri-eye-off-line' : 'i-ri-eye-line', 'h-3.5 w-3.5')} />
</button>
<button
type="button"
onClick={handleCopy}
@ -156,11 +152,10 @@ const ApiKeyRow: FC<ApiKeyRowProps> = ({ label, envName, value, onRevoke }) => {
const permissionIcon: Record<AccessPermissionKind, string> = {
organization: 'i-ri-team-line',
specific: 'i-ri-lock-line',
external: 'i-ri-user-line',
anyone: 'i-ri-global-line',
}
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'external', 'anyone']
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
type PermissionPickerProps = {
value: AccessPermissionKind
@ -246,14 +241,15 @@ const EndpointRow: FC<EndpointRowProps> = ({ envName, label, value, openLabel })
)
type ApiKeyGenerateMenuProps = {
environments: Environment[]
environments: ConsoleEnvironmentSummary[]
onGenerate: (environmentId: string) => void
}
const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGenerate }) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const disabled = environments.length === 0
const selectableEnvironments = environments.filter(env => env.id)
const disabled = selectableEnvironments.length === 0
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
@ -272,17 +268,17 @@ const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGener
</DropdownMenuTrigger>
{open && !disabled && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
{environments.map(env => (
{selectableEnvironments.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
onClick={() => {
setOpen(false)
onGenerate(env.id)
onGenerate(env.id!)
}}
>
<span className="system-sm-regular text-text-secondary">
{t('access.api.newKeyForEnv', { env: env.name })}
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
@ -303,61 +299,52 @@ function getUrlOrigin(url?: string) {
}
}
function uniqueEnvironments(environments: (ConsoleEnvironmentSummary | undefined)[]) {
return environments.filter((environment, index): environment is ConsoleEnvironmentSummary => {
if (!environment?.id)
return false
return environments.findIndex(candidate => candidate?.id === environment.id) === index
})
}
type AccessTabProps = {
instanceId: string
}
const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
const { t } = useTranslation('deployments')
const instances = useDeploymentsStore(state => state.instances)
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const apiKeys = useDeploymentsStore(state => state.apiKeys)
const access = useDeploymentsStore(state => state.access)
const appData = useDeploymentsStore(state => state.appData[appId])
const createdApiToken = useDeploymentsStore(state => state.createdApiToken)
const clearCreatedApiToken = useDeploymentsStore(state => state.clearCreatedApiToken)
const generateApiKey = useDeploymentsStore(state => state.generateApiKey)
const revokeApiKey = useDeploymentsStore(state => state.revokeApiKey)
const toggleAccessMethod = useDeploymentsStore(state => state.toggleAccessMethod)
const setEnvAccessPermission = useDeploymentsStore(state => state.setEnvAccessPermission)
const toggleAccessChannel = useDeploymentsStore(state => state.toggleAccessChannel)
const setEnvironmentAccessPolicy = useDeploymentsStore(state => state.setEnvironmentAccessPolicy)
const instance = instances.find(i => i.id === instanceId)
const instanceAccess = access.find(a => a.instanceId === instanceId)
const instanceDeployments = useMemo(
() => deployments.filter(d => d.instanceId === instanceId),
[deployments, instanceId],
const accessConfig = appData?.accessConfig
const deploymentRows = useMemo(
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
[appData?.environmentDeployments.environmentDeployments],
)
const envMap = useMemo(
() => new Map(environments.map(env => [env.id, env])),
[environments],
const policies = useMemo(
() => accessConfig?.userAccess?.environmentPolicies ?? [],
[accessConfig?.userAccess?.environmentPolicies],
)
const instanceKeys = useMemo(
() => apiKeys.filter(k => k.instanceId === instanceId),
[apiKeys, instanceId],
)
const deployedEnvs = useMemo(
() => instanceDeployments
.map(deployment => envMap.get(deployment.environmentId))
.filter((env): env is Environment => !!env),
[envMap, instanceDeployments],
() => uniqueEnvironments([
...deploymentRows.map(row => row.environment),
...policies.map(policy => policy.environment),
...(accessConfig?.webapp?.rows?.map(row => row.environment) ?? []),
]),
[accessConfig?.webapp?.rows, deploymentRows, policies],
)
const permissionByEnv = useMemo(() => {
const map = new Map<string, EnvAccessPermission>()
instanceAccess?.envPermissions.forEach((p) => {
map.set(p.environmentId, p)
})
return map
}, [instanceAccess])
if (!instance || !instanceAccess)
return null
const apiEnabled = instanceAccess.enabled.api
const runEnabled = instanceAccess.enabled.runAccess
const cliDomain = getUrlOrigin(instanceAccess.mcpUrl)
const webappRows = accessConfig?.webapp?.rows?.filter(row => row.url) ?? []
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
const runEnabled = accessConfig?.webapp?.enabled ?? false
const visibleCreatedApiToken = createdApiToken?.appId === appId ? createdApiToken : undefined
const webappChannelVersion = policies.find(policy => policy.effectivePolicy?.channel === 'webapp')?.effectivePolicy?.version ?? 0
const cliDomain = getUrlOrigin(accessConfig?.cli?.url)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
@ -375,8 +362,8 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
: (
<div className="flex flex-col gap-3">
{deployedEnvs.map((env) => {
const current = permissionByEnv.get(env.id)
const kind = current?.kind ?? 'organization'
const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy
const kind = accessModeToPermissionKey(policy?.accessMode)
return (
<div
key={env.id}
@ -384,11 +371,18 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
{env.name}
{environmentName(env)}
</span>
<PermissionPicker
value={kind}
onChange={next => setEnvAccessPermission(instanceId, env.id, next)}
onChange={next => setEnvironmentAccessPolicy(
appId,
env.id!,
policy?.channel ?? 'webapp',
true,
permissionKeyToAccessMode(next),
policy?.version ?? 0,
)}
/>
</div>
{kind === 'specific' && (
@ -409,7 +403,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
action={(
<Switch
checked={runEnabled}
onCheckedChange={v => toggleAccessMethod(instanceId, 'runAccess', v)}
onCheckedChange={v => toggleAccessChannel(appId, 'webapp', v, webappChannelVersion)}
/>
)}
>
@ -429,18 +423,22 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
<div className="system-xs-regular text-text-tertiary">
{t('access.runAccess.webappDesc')}
</div>
{instanceAccess.webappUrl && deployedEnvs.length > 0
{webappRows.length > 0
? (
<div className="flex flex-col gap-2">
{deployedEnvs.map(env => (
<EndpointRow
key={`webapp-${env.id}`}
envName={env.name}
label={t('access.runAccess.urlLabel')}
value={instanceAccess.webappUrl!}
openLabel={t('access.runAccess.openWebapp')}
/>
))}
{webappRows.map((row) => {
const endpointUrl = webappUrl(row.url)
return (
<EndpointRow
key={`webapp-${row.environment?.id ?? row.url}`}
envName={environmentName(row.environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
@ -511,7 +509,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
action={(
<Switch
checked={apiEnabled}
onCheckedChange={v => toggleAccessMethod(instanceId, 'api', v)}
onCheckedChange={v => toggleAccessChannel(appId, 'api', v, 0)}
/>
)}
>
@ -529,10 +527,36 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
</div>
<ApiKeyGenerateMenu
environments={deployedEnvs}
onGenerate={environmentId => generateApiKey(instanceId, environmentId)}
onGenerate={environmentId => generateApiKey(appId, environmentId)}
/>
</div>
{instanceKeys.length === 0
{visibleCreatedApiToken && (
<div className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.newTokenTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</span>
</div>
<button
type="button"
onClick={clearCreatedApiToken}
aria-label={t('access.api.dismissToken')}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-close-line h-3.5 w-3.5" />
</button>
</div>
<CopyPill
label={t('access.api.newTokenLabel')}
value={visibleCreatedApiToken.token}
/>
</div>
)}
{apiKeys.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{deployedEnvs.length === 0
@ -542,15 +566,14 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
)
: (
<div className="flex flex-col divide-y divide-divider-subtle">
{instanceKeys.map((k) => {
const env = envMap.get(k.environmentId)
{apiKeys.map((apiKey) => {
if (!apiKey.id || !apiKey.environmentId)
return null
return (
<ApiKeyRow
key={k.id}
label={k.label}
envName={env?.name ?? k.environmentId}
value={k.value}
onRevoke={() => revokeApiKey(k.id)}
key={apiKey.id}
apiKey={apiKey}
onRevoke={() => revokeApiKey(appId, apiKey.environmentId!, apiKey.id!)}
/>
)
})}

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Deployment, Environment, Release } from '../types'
import type { EnvironmentDeploymentRow } from '@/contract/console/deployments'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -12,7 +12,21 @@ import {
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { mockCredentials } from '../mock-data'
import {
activeRelease,
deployedRows,
deploymentId,
deploymentStatus,
environmentBackend,
environmentHealth,
environmentId,
environmentMode,
environmentName,
formatDate,
releaseCommit,
releaseLabel,
targetRelease,
} from '../api-utils'
import { HealthBadge, ModeBadge } from '../status-badge'
import { useDeploymentsStore } from '../store'
@ -48,103 +62,85 @@ const InfoRow: FC<InfoRowProps> = ({ label, value, mono, suffix }) => (
)
type DeploymentPanelProps = {
deployment: Deployment
env: Environment
release?: Release
targetRelease?: Release
failedRelease?: Release
row: EnvironmentDeploymentRow
}
const DeploymentPanel: FC<DeploymentPanelProps> = ({ deployment, env, release, targetRelease, failedRelease }) => {
const DeploymentPanel: FC<DeploymentPanelProps> = ({ row }) => {
const { t } = useTranslation('deployments')
const credentialMap = useMemo(
() => new Map(mockCredentials.map(c => [c.id, c])),
[],
)
const modelCreds = deployment.credentials.filter(c => c.kind === 'model')
const pluginCreds = deployment.credentials.filter(c => c.kind === 'plugin')
const observed = activeRelease(row)
const pending = targetRelease(row)
const env = row.environment
const observedBindings = row.observedRuntime?.bindings
const pendingBindings = row.pendingDeployment?.bindings
const credentials = [...observedBindings?.credentials ?? [], ...pendingBindings?.credentials ?? []]
const envVars = [...observedBindings?.envVars ?? [], ...pendingBindings?.envVars ?? []]
return (
<div className="border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<div className="mb-3 flex items-center gap-2">
<span className="system-sm-semibold text-text-primary">
{env.name}
{environmentName(env)}
{' · '}
{deployment.activeReleaseId}
{releaseLabel(observed || pending)}
</span>
<ModeBadge mode={env.mode} />
<HealthBadge health={env.health} />
<ModeBadge mode={environmentMode(env)} />
<HealthBadge health={environmentHealth(env)} />
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-4 md:grid-cols-2">
<InfoBlock title={t('deployTab.panel.instanceInfo')}>
<InfoRow label={t('deployTab.panel.deploymentId')} value={deployment.id} mono />
<InfoRow label={t('deployTab.panel.replicas')} value={deployment.replicas != null ? String(deployment.replicas) : '—'} />
<InfoRow label={t('deployTab.panel.runtimeMode')} value={t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${env.backend.toUpperCase()}`} />
<InfoRow label={t('deployTab.panel.runtimeNote')} value={deployment.runtimeNote ?? '—'} />
<InfoRow label={t('deployTab.panel.deploymentId')} value={deploymentId(row) || '—'} mono />
<InfoRow label={t('deployTab.panel.replicas')} value={row.instance?.replicas != null ? String(row.instance.replicas) : '—'} />
<InfoRow label={t('deployTab.panel.runtimeMode')} value={t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${environmentBackend(env).toUpperCase()}`} />
<InfoRow label={t('deployTab.panel.runtimeNote')} value={row.instance?.status ?? '—'} />
</InfoBlock>
<InfoBlock title={t('deployTab.panel.releaseInfo')}>
<InfoRow label={t('deployTab.panel.release')} value={release?.id ?? deployment.activeReleaseId} mono />
<InfoRow label={t('deployTab.panel.commit')} value={release?.gateCommitId ?? '—'} mono />
<InfoRow label={t('deployTab.panel.createdAt')} value={release?.createdAt ?? '—'} />
{targetRelease && (
<InfoRow label={t('deployTab.panel.targetRelease')} value={`${targetRelease.id} / ${targetRelease.gateCommitId}`} mono />
<InfoRow label={t('deployTab.panel.release')} value={releaseLabel(observed || pending)} mono />
<InfoRow label={t('deployTab.panel.commit')} value={releaseCommit(observed || pending)} mono />
<InfoRow label={t('deployTab.panel.createdAt')} value={formatDate((observed || pending)?.createdAt)} />
{pending && (
<InfoRow label={t('deployTab.panel.targetRelease')} value={`${releaseLabel(pending)} / ${releaseCommit(pending)}`} mono />
)}
{failedRelease && (
<InfoRow label={t('deployTab.panel.failedRelease')} value={`${failedRelease.id} / ${failedRelease.gateCommitId}`} mono />
{row.instance?.lastError?.releaseId && (
<InfoRow label={t('deployTab.panel.failedRelease')} value={row.instance.lastError.releaseId} mono />
)}
</InfoBlock>
<InfoBlock title={t('deployTab.panel.endpoints')}>
<InfoRow label={t('deployTab.panel.run')} value={`/${env.namespace}/run`} mono />
<InfoRow label={t('deployTab.panel.health')} value={`/${env.namespace}/readyz`} mono />
<InfoRow label={t('deployTab.panel.run')} value={row.observedRuntime?.endpoints?.run ?? '—'} mono />
<InfoRow label={t('deployTab.panel.health')} value={row.observedRuntime?.endpoints?.health ?? '—'} mono />
</InfoBlock>
{modelCreds.length > 0 && (
{credentials.length > 0 && (
<InfoBlock title={t('deployTab.panel.modelCreds')}>
{modelCreds.map(c => (
{credentials.map(c => (
<InfoRow
key={`model-${c.provider}`}
label={c.provider}
value={credentialMap.get(c.credentialId ?? '')?.name ?? '—'}
key={`${c.slot}-${c.displayName}-${c.maskedValue}`}
label={c.slot ?? '—'}
value={c.displayName || c.maskedValue || '—'}
mono
/>
))}
</InfoBlock>
)}
{pluginCreds.length > 0 && (
<InfoBlock title={t('deployTab.panel.pluginCreds')}>
{pluginCreds.map(c => (
<InfoRow
key={`plugin-${c.provider}`}
label={c.provider}
value={credentialMap.get(c.credentialId ?? '')?.name ?? '—'}
mono
/>
))}
</InfoBlock>
)}
{deployment.envVariables.length > 0 && (
{envVars.length > 0 && (
<InfoBlock title={t('deployTab.panel.envVars')}>
{deployment.envVariables.map(v => (
{envVars.map(v => (
<InfoRow
key={v.key}
label={v.key}
value={v.type === 'secret' ? '••••••' : v.value}
key={`${v.slot}-${v.displayName}`}
label={v.slot ?? '—'}
value={v.maskedValue || v.displayName || '—'}
mono
suffix={` (${v.type})`}
/>
))}
</InfoBlock>
)}
</div>
{deployment.status === 'deploy_failed' && deployment.errorMessage && (
{row.instance?.lastError?.message && (
<div className="mt-4 rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
{deployment.errorMessage}
{row.instance.lastError.message}
</div>
)}
</div>
@ -152,22 +148,23 @@ const DeploymentPanel: FC<DeploymentPanelProps> = ({ deployment, env, release, t
}
type DeploymentStatusSummaryProps = {
deployment: Deployment
row: EnvironmentDeploymentRow
}
const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ deployment }) => {
const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ row }) => {
const { t } = useTranslation('deployments')
const status = deploymentStatus(row)
if (deployment.status === 'deploying') {
if (status === 'deploying') {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
{t('deployTab.status.deployingRelease', { release: deployment.targetReleaseId ?? deployment.activeReleaseId })}
{t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease(row) || activeRelease(row)) })}
</span>
)
}
if (deployment.status === 'deploy_failed') {
if (status === 'deploy_failed') {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-warning-warning-700">
<span className="i-ri-alert-line h-3.5 w-3.5" />
@ -184,106 +181,26 @@ const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ deployment
)
}
type RowPrimaryActionProps = {
deployment: Deployment
onPromote: () => void
onViewProgress: () => void
onViewLogs: () => void
}
const RowPrimaryAction: FC<RowPrimaryActionProps> = ({ deployment, onPromote, onViewProgress, onViewLogs }) => {
const { t } = useTranslation('deployments')
if (deployment.status === 'deploying') {
return (
<Button size="small" variant="secondary" onClick={onViewProgress}>
{t('deployTab.viewProgress')}
</Button>
)
}
if (deployment.status === 'deploy_failed') {
return (
<Button size="small" variant="secondary" onClick={onViewLogs}>
{t('deployTab.viewLogs')}
</Button>
)
}
return (
<Button size="small" variant="secondary" onClick={onPromote}>
{t('deployTab.deployOtherVersion')}
</Button>
)
}
type DeploymentMenuProps = {
deployment: Deployment
onUndeploy: () => void
}
const DeploymentMenu: FC<DeploymentMenuProps> = ({ deployment, onUndeploy }) => {
const { t } = useTranslation('deployments')
const [menuOpen, setMenuOpen] = useState(false)
const itemLabel = deployment.status === 'deploying'
? t('deployTab.cancelDeployment')
: t('deployTab.undeploy')
return (
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className="flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-more-line h-4 w-4" />
</DropdownMenuTrigger>
{menuOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setMenuOpen(false)
onUndeploy()
}}
>
<span className="system-sm-regular text-text-destructive">
{itemLabel}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
type DeployTabProps = {
instanceId: string
}
const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
const DeployTab: FC<DeployTabProps> = ({ instanceId: appId }) => {
const { t } = useTranslation('deployments')
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const appData = useDeploymentsStore(state => state.appData[appId])
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const undeployDeployment = useDeploymentsStore(state => state.undeployDeployment)
const releases = useDeploymentsStore(state => state.releases)
const instanceDeployments = useMemo(
() => deployments.filter(d => d.instanceId === instanceId),
[deployments, instanceId],
const rows = useMemo(
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
[appData?.environmentDeployments.environmentDeployments],
)
const envMap = useMemo(
() => new Map(environments.map(env => [env.id, env])),
[environments],
)
const [expanded, setExpanded] = useState<string | null>(() => instanceDeployments[0]?.id ?? null)
const deployedEnvIds = new Set(rows.map(row => environmentId(row.environment)))
const availableEnvs = appData?.candidates.environmentOptions?.filter(env => env.id && !deployedEnvIds.has(env.id)) ?? []
const [expanded, setExpanded] = useState<string | null>(() => rows[0] ? environmentId(rows[0].environment) : null)
const toggle = (id: string) => setExpanded(prev => (prev === id ? null : id))
const [deployMenuOpen, setDeployMenuOpen] = useState(false)
const availableEnvs = environments.filter(env => !instanceDeployments.some(d => d.environmentId === env.id))
return (
<div className="flex flex-col gap-4 p-6">
@ -293,7 +210,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
{' '}
<span className="system-sm-regular text-text-tertiary">
(
{instanceDeployments.length}
{rows.length}
)
</span>
</div>
@ -315,7 +232,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
className="gap-2 px-3"
onClick={() => {
setDeployMenuOpen(false)
openDeployDrawer({ instanceId })
openDeployDrawer({ appId })
}}
>
<span className="system-sm-regular text-text-secondary">{t('deployTab.deployToNewEnv')}</span>
@ -329,11 +246,11 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
className="gap-2 px-3"
onClick={() => {
setDeployMenuOpen(false)
openDeployDrawer({ instanceId, environmentId: env.id })
openDeployDrawer({ appId, environmentId: env.id })
}}
>
<span className="system-sm-regular text-text-secondary">
{t('deployTab.deployToEnv', { name: env.name })}
{t('deployTab.deployToEnv', { name: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
@ -344,7 +261,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
</DropdownMenu>
</div>
{instanceDeployments.length === 0
{rows.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('deployTab.empty')}
@ -362,26 +279,34 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
<div>{t('deployTab.col.status')}</div>
<div />
</div>
{instanceDeployments.map((deployment) => {
const env = envMap.get(deployment.environmentId)
if (!env)
return null
const isExpanded = expanded === deployment.id
const release = releases.find(r => r.id === deployment.activeReleaseId)
const targetRelease = deployment.targetReleaseId ? releases.find(r => r.id === deployment.targetReleaseId) : undefined
const failedRelease = deployment.failedReleaseId ? releases.find(r => r.id === deployment.failedReleaseId) : undefined
{rows.map((row) => {
const envId = environmentId(row.environment)
const isExpanded = expanded === envId
const status = deploymentStatus(row)
const release = activeRelease(row) || targetRelease(row)
const actions = (
<div className="flex shrink-0 items-center gap-1" onClick={e => e.stopPropagation()}>
<RowPrimaryAction
deployment={deployment}
onPromote={() => openDeployDrawer({ instanceId, environmentId: deployment.environmentId })}
onViewProgress={() => setExpanded(deployment.id)}
onViewLogs={() => setExpanded(deployment.id)}
/>
<DeploymentMenu
deployment={deployment}
onUndeploy={() => undeployDeployment(deployment.id)}
/>
<Button size="small" variant="secondary" onClick={() => openDeployDrawer({ appId, environmentId: envId })}>
{status === 'ready' ? t('deployTab.deployOtherVersion') : t('deployTab.viewProgress')}
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className="flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-more-line h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => undeployDeployment(appId, envId, deploymentId(row), status === 'deploying')}
>
<span className="system-sm-regular text-text-destructive">
{status === 'deploying' ? t('deployTab.cancelDeployment') : t('deployTab.undeploy')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
const chevron = (
@ -393,10 +318,10 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
/>
)
return (
<div key={deployment.id} className="border-b border-divider-subtle last:border-b-0">
<div key={envId} className="border-b border-divider-subtle last:border-b-0">
<button
type="button"
onClick={() => toggle(deployment.id)}
onClick={() => toggle(envId)}
className={cn(
'flex w-full flex-col gap-2 px-4 py-3 text-left hover:bg-state-base-hover',
'lg:grid lg:items-center lg:gap-4',
@ -405,11 +330,11 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
>
<div className="flex items-start justify-between gap-3 lg:block">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate system-sm-semibold text-text-primary">{env.name}</span>
<span className="truncate system-sm-semibold text-text-primary">{environmentName(row.environment)}</span>
<div className="flex items-center gap-1.5 system-xs-regular text-text-tertiary">
<span className="uppercase">{env.backend}</span>
<span className="uppercase">{environmentBackend(row.environment)}</span>
<span>·</span>
<span>{t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')}</span>
<span>{t(environmentMode(row.environment) === 'isolated' ? 'mode.isolated' : 'mode.shared')}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 lg:hidden">
@ -419,13 +344,11 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 lg:contents">
<div className="flex items-center gap-2">
<span className="font-mono system-sm-medium text-text-primary">{deployment.activeReleaseId}</span>
{release && (
<span className="font-mono system-xs-regular text-text-tertiary">{release.gateCommitId}</span>
)}
<span className="font-mono system-sm-medium text-text-primary">{releaseLabel(release)}</span>
<span className="font-mono system-xs-regular text-text-tertiary">{releaseCommit(release)}</span>
</div>
<div>
<DeploymentStatusSummary deployment={deployment} />
<DeploymentStatusSummary row={row} />
</div>
</div>
<div className="hidden items-center justify-end gap-1 lg:flex">
@ -433,15 +356,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
{chevron}
</div>
</button>
{isExpanded && (
<DeploymentPanel
deployment={deployment}
env={env}
release={release}
targetRelease={targetRelease}
failedRelease={failedRelease}
/>
)}
{isExpanded && <DeploymentPanel row={row} />}
</div>
)
})}

View File

@ -19,6 +19,7 @@ import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import { deployedRows, deploymentStatus } from '../api-utils'
import DeployDrawer from '../deploy-drawer'
import RollbackModal from '../rollback-modal'
import { useDeploymentsStore } from '../store'
@ -219,19 +220,18 @@ const InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
const instances = useDeploymentsStore(state => state.instances)
const deployments = useDeploymentsStore(state => state.deployments)
const sourceApps = useDeploymentsStore(state => state.sourceApps)
const appData = useDeploymentsStore(state => state.appData)
const { appMap, isLoading: isLoadingApps } = useSourceApps()
useDocumentTitle(t('documentTitle.detail'))
const instance = useMemo(() => instances.find(i => i.id === instanceId), [instances, instanceId])
const app = useMemo(
() => instance ? appMap.get(instance.appId) : undefined,
[instance, appMap],
() => sourceApps.find(item => item.id === instanceId) ?? appMap.get(instanceId),
[sourceApps, instanceId, appMap],
)
const instanceDeployments = useMemo(
() => instance ? deployments.filter(d => d.instanceId === instance.id) : [],
[deployments, instance],
const appDeployments = useMemo(
() => deployedRows(appData[instanceId]?.environmentDeployments.environmentDeployments),
[appData, instanceId],
)
if (isLoadingApps && !app) {
@ -242,7 +242,7 @@ const InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
)
}
if (!instance) {
if (!app) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-body">
<div className="title-xl-semi-bold text-text-primary">{t('detail.notFound')}</div>
@ -254,8 +254,8 @@ const InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
)
}
const deployingCount = instanceDeployments.filter(d => d.status === 'deploying').length
const failedCount = instanceDeployments.filter(d => d.status === 'deploy_failed').length
const deployingCount = appDeployments.filter(row => deploymentStatus(row) === 'deploying').length
const failedCount = appDeployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
const appModeLabel = app ? getAppModeLabel(app.mode, tCommon) : t('detail.sourceAppDeleted')
return (
@ -263,8 +263,8 @@ const InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
<div className="relative flex h-full overflow-hidden rounded-t-2xl shadow-[0_0_5px_rgba(0,0,0,0.05),0_0_2px_-1px_rgba(0,0,0,0.03)]">
<DeploymentSidebar
instanceId={instanceId}
instanceName={instance.name}
instanceDescription={instance.description}
instanceName={app.name}
instanceDescription={app.description}
appModeLabel={appModeLabel}
app={app}
/>
@ -279,7 +279,7 @@ const InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
<div className="system-xs-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
<div className="flex items-center gap-2 system-xs-regular text-text-tertiary">
<span>{t('detail.envCount', { count: instanceDeployments.length })}</span>
<span>{t('detail.envCount', { count: appDeployments.length })}</span>
{deployingCount > 0 && (
<>
<span>·</span>

View File

@ -1,14 +1,12 @@
'use client'
import type { FC } from 'react'
import type { AppInfo } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiArrowRightUpLine, RiErrorWarningLine, RiExchangeLine, RiRocketLine } from '@remixicon/react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AppPicker } from '../create-instance-modal'
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
import { deployedRows, deploymentStatus, environmentName, formatDate, releaseLabel, webappUrl } from '../api-utils'
import { StatusBadge } from '../status-badge'
import { useDeploymentsStore } from '../store'
import { useSourceApps } from '../use-source-apps'
@ -47,83 +45,6 @@ const InfoRow: FC<InfoRowProps> = ({ label, value, mono }) => (
</div>
)
type SwitchSourceAppDialogProps = {
open: boolean
instanceId: string
currentAppId: string
apps: AppInfo[]
isLoading: boolean
onClose: () => void
}
const SwitchSourceAppDialog: FC<SwitchSourceAppDialogProps> = ({
open,
instanceId,
currentAppId,
apps,
isLoading,
onClose,
}) => {
const { t } = useTranslation('deployments')
const switchSourceApp = useDeploymentsStore(state => state.switchSourceApp)
const [selectedAppId, setSelectedAppId] = useState('')
const currentAppExists = apps.some(app => app.id === currentAppId)
const pickerValue = selectedAppId || (currentAppExists ? currentAppId : '')
const canSwitch = Boolean(pickerValue && pickerValue !== currentAppId)
const handleSwitch = () => {
if (!canSwitch)
return
switchSourceApp(instanceId, pickerValue)
onClose()
}
return (
<Dialog open={open} onOpenChange={next => !next && onClose()}>
<DialogContent className="w-[520px] max-w-[90vw]">
<DialogCloseButton />
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('overview.switchSourceApp')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('overview.switchSourceAppDescription')}
</DialogDescription>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary">
{t('createModal.sourceApp')}
</label>
<AppPicker
apps={apps}
isLoading={isLoading}
value={pickerValue}
onChange={setSelectedAppId}
/>
</div>
<div className="rounded-lg border border-components-panel-border bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary">
{t('overview.switchSourceAppHint')}
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{t('createModal.cancel')}
</Button>
<Button variant="primary" disabled={!canSwitch} onClick={handleSwitch}>
{t('overview.switchSourceApp')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
type AccessOverviewRowProps = {
label: string
enabled: boolean
@ -157,221 +78,108 @@ const AccessOverviewRow: FC<AccessOverviewRowProps> = ({ label, enabled, hint })
const OverviewTab: FC<OverviewTabProps> = ({ instanceId, onSwitchTab }) => {
const { t } = useTranslation('deployments')
const instances = useDeploymentsStore(state => state.instances)
const deployments = useDeploymentsStore(state => state.deployments)
const environments = useDeploymentsStore(state => state.environments)
const access = useDeploymentsStore(state => state.access)
const apiKeys = useDeploymentsStore(state => state.apiKeys)
const { t: tCommon } = useTranslation()
const appData = useDeploymentsStore(state => state.appData[instanceId])
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const [switchSourceOpen, setSwitchSourceOpen] = useState(false)
const { appMap } = useSourceApps()
const app = appMap.get(instanceId)
const { apps, appMap, isLoading: isLoadingApps } = useSourceApps()
const instance = instances.find(i => i.id === instanceId)
const app = instance ? appMap.get(instance.appId) : undefined
const sourceAppMissing = Boolean(instance && !isLoadingApps && !app)
const instanceDeployments = useMemo(
() => deployments.filter(d => d.instanceId === instanceId),
[deployments, instanceId],
)
const instanceAccess = access.find(a => a.instanceId === instanceId)
const instanceKeys = useMemo(
() => apiKeys.filter(k => k.instanceId === instanceId),
[apiKeys, instanceId],
const deployments = useMemo(
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
[appData?.environmentDeployments.environmentDeployments],
)
const envMap = useMemo(
() => new Map(environments.map(env => [env.id, env])),
[environments],
)
if (!instance)
if (!app)
return null
const runAccessEnabled = instanceAccess?.enabled.runAccess ?? false
const apiAccessEnabled = instanceAccess?.enabled.api ?? false
const endUserAccessEntries: AccessOverviewRowProps[] = [
{
label: t('overview.webapp'),
enabled: runAccessEnabled && Boolean(instanceAccess?.webappUrl),
hint: instanceAccess?.webappUrl ?? t('overview.notConfigured'),
},
{
label: t('overview.cli'),
enabled: runAccessEnabled && Boolean(instanceAccess?.mcpUrl),
hint: instanceAccess?.mcpUrl ?? t('overview.notConfigured'),
},
]
const developerAccessEntries: AccessOverviewRowProps[] = [
{
label: t('overview.api'),
enabled: apiAccessEnabled,
hint: apiAccessEnabled
? t('overview.apiKeysCount', { count: instanceKeys.length })
: t('overview.notConfigured'),
},
]
const appModeLabel = app
? t(`appMode.${app.mode}`, { defaultValue: app.mode })
: t('overview.sourceAppUnavailable')
const appModeLabel = getAppModeLabel(app.mode, tCommon)
const webappRow = appData?.accessConfig.webapp?.rows?.find(row => row.url)
const webappAccessUrl = webappUrl(webappRow?.url)
const cliUrl = appData?.accessConfig.cli?.url
const apiKeysCount = appData?.accessConfig.developerApi?.apiKeys?.length ?? 0
return (
<>
<div className="flex flex-col gap-5 p-6">
{sourceAppMissing && (
<div className="flex gap-3 rounded-xl border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 p-4">
<RiErrorWarningLine className="mt-0.5 h-4 w-4 shrink-0 text-util-colors-warning-warning-700" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="system-sm-semibold text-util-colors-warning-warning-700">
{t('overview.sourceAppDeletedTitle')}
</div>
<div className="system-xs-regular text-text-secondary">
{t('overview.sourceAppDeletedDescription')}
</div>
<button
type="button"
onClick={() => setSwitchSourceOpen(true)}
className="mt-1 flex items-center gap-1 self-start system-xs-medium text-text-accent hover:underline"
>
<RiExchangeLine className="h-3 w-3" />
{t('overview.switchSourceApp')}
</button>
</div>
</div>
<div className="flex flex-col gap-5 p-6">
<Section title={t('overview.basicInfo')}>
<div className="flex flex-col divide-y divide-divider-subtle">
<InfoRow label={t('overview.name')} value={app.name} />
<InfoRow label={t('overview.description')} value={app.description ?? t('overview.emptyValue')} />
<InfoRow label={t('overview.sourceApp')} value={app.name} />
<InfoRow label={t('overview.appMode')} value={appModeLabel} />
</div>
</Section>
<Section
title={t('overview.deploymentStatus')}
action={(
<Button size="small" variant="secondary" onClick={() => onSwitchTab?.('deploy')}>
{t('overview.viewDeployments')}
<span className="i-ri-arrow-right-up-line h-3.5 w-3.5" />
</Button>
)}
<Section title={t('overview.basicInfo')}>
<div className="flex flex-col divide-y divide-divider-subtle">
<InfoRow label={t('overview.name')} value={instance.name} />
<InfoRow label={t('overview.description')} value={instance.description ?? t('overview.emptyValue')} />
<InfoRow
label={t('overview.sourceApp')}
value={(
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className={cn('min-w-0 truncate', sourceAppMissing && 'text-util-colors-warning-warning-700')}>
{app?.name ?? t('overview.sourceAppDeletedValue')}
</span>
<button
type="button"
onClick={() => setSwitchSourceOpen(true)}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-accent hover:underline"
>
<RiExchangeLine className="h-3 w-3" />
{t('overview.switchSourceApp')}
</button>
</div>
)}
/>
<InfoRow label={t('overview.appMode')} value={appModeLabel} />
<InfoRow label={t('overview.instanceId')} value={instance.id} mono />
<InfoRow label={t('overview.created')} value={instance.createdAt} />
</div>
</Section>
<Section
title={t('overview.deploymentStatus')}
action={onSwitchTab && (
<button
type="button"
onClick={() => onSwitchTab('deploy')}
className="flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
>
{t('overview.viewDeployments')}
<RiArrowRightUpLine className="h-3 w-3" />
</button>
)}
>
{instanceDeployments.length === 0
? (
<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">
<div className="system-sm-regular text-text-tertiary">
{t('overview.notDeployedYet')}
</div>
<Button
variant="secondary"
size="small"
onClick={() => openDeployDrawer({ instanceId })}
>
<RiRocketLine className="h-3.5 w-3.5" />
{t('overview.deploy')}
</Button>
</div>
)
: (
<div className="flex flex-col divide-y divide-divider-subtle">
{instanceDeployments.map((deployment) => {
const env = envMap.get(deployment.environmentId)
if (!env)
return null
return (
<div key={deployment.id} className="flex items-center justify-between py-2.5">
<div className="flex items-center gap-3">
<div className="flex flex-col">
<span className="system-sm-semibold text-text-primary">{env.name}</span>
<span className="system-xs-regular text-text-tertiary">
{t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')}
{' · '}
{env.backend.toUpperCase()}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<span className="font-mono system-sm-regular text-text-secondary">
{deployment.activeReleaseId}
</span>
<StatusBadge status={deployment.status} />
</div>
>
{deployments.length === 0
? (
<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({ appId: app.id })}>
{t('overview.deploy')}
</Button>
</div>
)
: (
<div className="flex flex-col divide-y divide-divider-subtle">
{deployments.map((row) => {
const status = deploymentStatus(row)
return (
<div key={row.environment?.id} className="flex items-center justify-between gap-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">{environmentName(row.environment)}</span>
<span className="system-xs-regular text-text-tertiary">
{releaseLabel(row.observedRuntime?.release || row.pendingDeployment?.release)}
{' · '}
{formatDate(row.instance?.lastDeployedAt || row.instance?.lastReadyAt)}
</span>
</div>
)
})}
</div>
)}
</Section>
<Section
title={t('overview.accessStatus')}
action={onSwitchTab && (
<button
type="button"
onClick={() => onSwitchTab('access')}
className="flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
>
{t('overview.configureAccess')}
<RiArrowRightUpLine className="h-3 w-3" />
</button>
)}
>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2 rounded-lg border border-divider-subtle bg-background-default-subtle p-3">
<div className="system-xs-semibold text-text-primary">{t('overview.endUserAccess')}</div>
<div className="flex flex-col divide-y divide-divider-subtle">
{endUserAccessEntries.map(entry => (
<AccessOverviewRow key={entry.label} {...entry} />
))}
<StatusBadge status={status} />
</div>
)
})}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-divider-subtle bg-background-default-subtle p-3">
<div className="system-xs-semibold text-text-primary">{t('overview.developerApi')}</div>
<div className="flex flex-col divide-y divide-divider-subtle">
{developerAccessEntries.map(entry => (
<AccessOverviewRow key={entry.label} {...entry} />
))}
</div>
</div>
</div>
</Section>
</div>
)}
</Section>
<SwitchSourceAppDialog
open={switchSourceOpen}
instanceId={instance.id}
currentAppId={instance.appId}
apps={apps}
isLoading={isLoadingApps}
onClose={() => setSwitchSourceOpen(false)}
/>
</>
<Section
title={t('overview.accessStatus')}
action={(
<Button size="small" variant="secondary" onClick={() => onSwitchTab?.('access')}>
{t('overview.configureAccess')}
<span className="i-ri-arrow-right-up-line h-3.5 w-3.5" />
</Button>
)}
>
<div className="flex flex-col divide-y divide-divider-subtle">
<AccessOverviewRow
label={t('overview.webapp')}
enabled={appData?.accessConfig.webapp?.enabled ?? false}
hint={webappAccessUrl || t('overview.notConfigured')}
/>
<AccessOverviewRow
label={t('overview.cli')}
enabled={appData?.accessConfig.cli?.enabled ?? false}
hint={cliUrl ?? t('overview.notConfigured')}
/>
<AccessOverviewRow
label={t('overview.api')}
enabled={appData?.accessConfig.developerApi?.enabled ?? false}
hint={appData?.accessConfig.developerApi?.enabled
? t('overview.apiKeysCount', { count: apiKeysCount })
: t('overview.notConfigured')}
/>
</div>
</Section>
</div>
)
}

View File

@ -1,38 +1,40 @@
'use client'
import type { FC } from 'react'
import type { Instance } from '../types'
import type { AppInfo } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { deployedRows } from '../api-utils'
import { useDeploymentsStore } from '../store'
import { useSourceApps } from '../use-source-apps'
type SettingsTabProps = {
instanceId: string
}
type SettingsFormProps = {
instance: Instance
app: AppInfo
hasDeployments: boolean
}
const SettingsForm: FC<SettingsFormProps> = ({ instance, hasDeployments }) => {
const SettingsForm: FC<SettingsFormProps> = ({ app, hasDeployments }) => {
const { t } = useTranslation('deployments')
const router = useRouter()
const updateInstance = useDeploymentsStore(state => state.updateInstance)
const deleteInstance = useDeploymentsStore(state => state.deleteInstance)
const [name, setName] = useState(instance.name)
const [description, setDescription] = useState(instance.description ?? '')
const [name, setName] = useState(app.name)
const [description, setDescription] = useState(app.description ?? '')
const dirty = name !== instance.name || description !== (instance.description ?? '')
const dirty = name !== app.name || description !== (app.description ?? '')
const handleSave = () => {
if (!name.trim())
return
updateInstance(instance.id, {
updateInstance(app.id, {
name: name.trim(),
description: description.trim() || undefined,
})
@ -40,8 +42,8 @@ const SettingsForm: FC<SettingsFormProps> = ({ instance, hasDeployments }) => {
}
const handleReset = () => {
setName(instance.name)
setDescription(instance.description ?? '')
setName(app.name)
setDescription(app.description ?? '')
}
const handleDelete = () => {
@ -49,7 +51,7 @@ const SettingsForm: FC<SettingsFormProps> = ({ instance, hasDeployments }) => {
toast.error(t('settings.undeployFirst'))
return
}
deleteInstance(instance.id)
deleteInstance(app.id)
router.push('/deployments')
}
@ -116,18 +118,18 @@ const SettingsForm: FC<SettingsFormProps> = ({ instance, hasDeployments }) => {
}
const SettingsTab: FC<SettingsTabProps> = ({ instanceId }) => {
const instances = useDeploymentsStore(state => state.instances)
const deployments = useDeploymentsStore(state => state.deployments)
const sourceApps = useDeploymentsStore(state => state.sourceApps)
const appData = useDeploymentsStore(state => state.appData[instanceId])
const { appMap } = useSourceApps()
const app = sourceApps.find(item => item.id === instanceId) ?? appMap.get(instanceId)
const instance = instances.find(i => i.id === instanceId)
if (!instance)
if (!app)
return null
const hasDeployments = deployments.some(d => d.instanceId === instanceId)
const formKey = `${instance.id}-${instance.name}-${instance.description ?? ''}`
const hasDeployments = deployedRows(appData?.environmentDeployments.environmentDeployments).length > 0
const formKey = `${app.id}-${app.name}-${app.description ?? ''}`
return <SettingsForm key={formKey} instance={instance} hasDeployments={hasDeployments} />
return <SettingsForm key={formKey} app={app} hasDeployments={hasDeployments} />
}
export default SettingsTab

View File

@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import type { DeployedToSummary, ReleaseHistoryRow } from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -10,6 +11,18 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
activeRelease,
deployedRows,
deploymentId,
deploymentStatus,
environmentId,
environmentName,
formatDate,
releaseCommit,
releaseLabel,
targetRelease,
} from '../api-utils'
import { useDeploymentsStore } from '../store'
const GRID_TEMPLATE = 'grid-cols-[0.9fr_1fr_0.8fr_1.5fr_auto]'
@ -28,20 +41,47 @@ const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
failed: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
}
type DeployReleaseMenuProps = {
releaseId: string
instanceId: string
function releaseDeploymentState(status?: string): ReleaseDeploymentState {
const normalized = status?.toLowerCase() ?? ''
if (normalized.includes('deploying') || normalized.includes('pending'))
return 'deploying'
if (normalized.includes('fail') || normalized.includes('error'))
return 'failed'
return 'active'
}
const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ releaseId, instanceId }) => {
function fromDeployedTo(item: DeployedToSummary): ReleaseDeployment | undefined {
if (!item.environmentId)
return undefined
return {
environmentId: item.environmentId,
environmentName: item.environmentName || item.environmentId,
state: releaseDeploymentState(item.instanceStatus),
}
}
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
return items.filter((item, index) => {
const key = `${item.environmentId}-${item.state}`
return items.findIndex(candidate => `${candidate.environmentId}-${candidate.state}` === key) === index
})
}
type DeployReleaseMenuProps = {
appId: string
releaseId: string
}
const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ appId, releaseId }) => {
const { t } = useTranslation('deployments')
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const appData = useDeploymentsStore(state => state.appData[appId])
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const openRollbackModal = useDeploymentsStore(state => state.openRollbackModal)
const [open, setOpen] = useState(false)
const instanceDeployments = deployments.filter(d => d.instanceId === instanceId)
const environments = appData?.candidates.environmentOptions?.filter(env => env.id) ?? []
const deploymentRows = deployedRows(appData?.environmentDeployments.environmentDeployments)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
@ -58,36 +98,40 @@ const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ releaseId, instanceId }
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
{environments.map((env) => {
const deployment = instanceDeployments.find(d => d.environmentId === env.id)
const isCurrent = deployment?.activeReleaseId === releaseId
const isEnvironmentDeploying = deployment?.status === 'deploying'
const envId = env.id!
const row = deploymentRows.find(item => environmentId(item.environment) === envId)
const isCurrent = activeRelease(row)?.id === releaseId
const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false
const disabled = Boolean(env.disabled || isCurrent || isEnvironmentDeploying)
return (
<DropdownMenuItem
key={env.id}
key={envId}
className="gap-2 px-3"
disabled={isCurrent || isEnvironmentDeploying}
disabled={disabled}
onClick={() => {
setOpen(false)
if (isCurrent || isEnvironmentDeploying)
if (disabled)
return
if (deployment) {
if (row) {
openRollbackModal({
deploymentId: deployment.id,
appId,
environmentId: envId,
deploymentId: deploymentId(row),
targetReleaseId: releaseId,
})
return
}
openDeployDrawer({ instanceId, environmentId: env.id, releaseId })
openDeployDrawer({ appId, environmentId: envId, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{isEnvironmentDeploying
? t('versions.deployingTo', { name: env.name })
? t('versions.deployingTo', { name: environmentName(env) })
: isCurrent
? t('versions.currentOn', { name: env.name })
: deployment
? t('versions.promoteTo', { name: env.name })
: t('versions.deployTo', { name: env.name })}
? t('versions.currentOn', { name: environmentName(env) })
: row
? t('versions.promoteTo', { name: environmentName(env) })
: t('versions.deployTo', { name: environmentName(env) })}
</span>
</DropdownMenuItem>
)
@ -98,46 +142,6 @@ const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ releaseId, instanceId }
)
}
type ReleaseMoreMenuProps = {
previewVisible: boolean
onTogglePreview: () => void
}
const ReleaseMoreMenu: FC<ReleaseMoreMenuProps> = ({ previewVisible, onTogglePreview }) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('versions.moreActions')}
className={cn(
open ? 'bg-state-base-hover text-text-secondary' : 'text-text-tertiary',
'flex h-7 w-7 items-center justify-center rounded-md hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span className="i-ri-more-line h-4 w-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[180px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setOpen(false)
onTogglePreview()
}}
>
<span className="i-ri-file-code-line h-4 w-4 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{previewVisible ? t('versions.hideYaml') : t('versions.viewYaml')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
const DeployedToBadge: FC<{ item: ReleaseDeployment }> = ({ item }) => {
const { t } = useTranslation('deployments')
const statusLabel = t(`versions.deployedStatus.${item.state}`)
@ -174,71 +178,55 @@ type VersionsTabProps = {
instanceId: string
}
const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
const { t } = useTranslation('deployments')
const instances = useDeploymentsStore(state => state.instances)
const releases = useDeploymentsStore(state => state.releases)
const deployments = useDeploymentsStore(state => state.deployments)
const environments = useDeploymentsStore(state => state.environments)
const instance = instances.find(i => i.id === instanceId)
const instanceDeployments = useMemo(
() => deployments.filter(d => d.instanceId === instanceId),
[deployments, instanceId],
const appData = useDeploymentsStore(state => state.appData[appId])
const releaseRows = useMemo(
() => appData?.releaseHistory.data?.filter(row => row.release?.id) ?? [],
[appData?.releaseHistory.data],
)
const deploymentRows = useMemo(
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
[appData?.environmentDeployments.environmentDeployments],
)
const appReleases = useMemo(() => {
if (!instance)
const getReleaseDeployments = (row: ReleaseHistoryRow) => {
const releaseId = row.release?.id
if (!releaseId)
return []
const deployedReleaseIds = new Set<string>()
instanceDeployments.forEach((deployment) => {
deployedReleaseIds.add(deployment.activeReleaseId)
if (deployment.targetReleaseId)
deployedReleaseIds.add(deployment.targetReleaseId)
if (deployment.failedReleaseId)
deployedReleaseIds.add(deployment.failedReleaseId)
})
return releases.filter(r => r.appId === instance.appId || deployedReleaseIds.has(r.id))
}, [releases, instance, instanceDeployments])
const [previewId, setPreviewId] = useState<string | null>(null)
if (!instance)
return null
const envMap = new Map(environments.map(env => [env.id, env]))
const getReleaseDeployments = (releaseId: string) => {
return instanceDeployments.flatMap((deployment) => {
const env = envMap.get(deployment.environmentId)
if (!env)
const historyItems = row.deployedTo?.map(fromDeployedTo).filter((item): item is ReleaseDeployment => !!item) ?? []
const runtimeItems = deploymentRows.flatMap((deployment) => {
const envId = environmentId(deployment.environment)
if (!envId)
return []
const items: ReleaseDeployment[] = []
if (deployment.activeReleaseId === releaseId) {
if (activeRelease(deployment)?.id === releaseId) {
items.push({
environmentId: deployment.environmentId,
environmentName: env.name,
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'active',
})
}
if (deployment.status === 'deploying' && deployment.targetReleaseId === releaseId) {
if (targetRelease(deployment)?.id === releaseId) {
items.push({
environmentId: deployment.environmentId,
environmentName: env.name,
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'deploying',
})
}
if (deployment.status === 'deploy_failed' && deployment.failedReleaseId === releaseId) {
if (deployment.instance?.lastError?.releaseId === releaseId) {
items.push({
environmentId: deployment.environmentId,
environmentName: env.name,
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'failed',
})
}
return items
})
return dedupeReleaseDeployments([...historyItems, ...runtimeItems])
}
return (
@ -249,13 +237,13 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
{' '}
<span className="system-sm-regular text-text-tertiary">
(
{appReleases.length}
{releaseRows.length}
)
</span>
</div>
</div>
{appReleases.length === 0
{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')}
@ -275,9 +263,9 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
<div className="text-right">{t('versions.col.action')}</div>
</div>
{appReleases.map((release) => {
const releaseDeployments = getReleaseDeployments(release.id)
const isPreview = previewId === release.id
{releaseRows.map((row) => {
const release = row.release!
const releaseDeployments = getReleaseDeployments(row)
return (
<div key={release.id} className="border-b border-divider-subtle last:border-b-0">
<div className="flex flex-col gap-3 px-4 py-3 lg:hidden">
@ -290,26 +278,22 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
<TooltipTrigger
render={(
<span className="mt-1 inline-flex max-w-full cursor-default truncate font-mono system-sm-medium text-text-primary">
{release.id}
{releaseLabel(release)}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: release.gateCommitId })}
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-tertiary">
<span>{release.createdAt}</span>
<span>{formatDate(release.createdAt)}</span>
<span aria-hidden>·</span>
<span>{release.operator}</span>
<span>{row.createdBy?.displayName ?? '—'}</span>
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu releaseId={release.id} instanceId={instanceId} />
<ReleaseMoreMenu
previewVisible={isPreview}
onTogglePreview={() => setPreviewId(prev => (prev === release.id ? null : release.id))}
/>
<DeployReleaseMenu releaseId={release.id!} appId={appId} />
</div>
</div>
<div>
@ -338,17 +322,17 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
<TooltipTrigger
render={(
<span className="inline-flex cursor-default font-mono system-sm-medium text-text-primary">
{release.id}
{releaseLabel(release)}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: release.gateCommitId })}
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
</div>
<div className="system-sm-regular text-text-secondary">{release.createdAt}</div>
<div className="system-sm-regular text-text-secondary">{release.operator}</div>
<div className="system-sm-regular text-text-secondary">{formatDate(release.createdAt)}</div>
<div className="system-sm-regular text-text-secondary">{row.createdBy?.displayName ?? '—'}</div>
<div className="flex flex-wrap gap-1">
{releaseDeployments.length === 0
? <span className="system-sm-regular text-text-quaternary"></span>
@ -360,20 +344,9 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
))}
</div>
<div className="flex justify-end gap-1">
<DeployReleaseMenu releaseId={release.id} instanceId={instanceId} />
<ReleaseMoreMenu
previewVisible={isPreview}
onTogglePreview={() => setPreviewId(prev => (prev === release.id ? null : release.id))}
/>
<DeployReleaseMenu releaseId={release.id!} appId={appId} />
</div>
</div>
{isPreview && (
<div className="border-t border-divider-subtle bg-background-default-subtle">
<pre className="overflow-auto px-4 py-3 font-mono text-[12.5px] leading-5 text-text-secondary">
{release.yaml}
</pre>
</div>
)}
</div>
)
})}

View File

@ -1,407 +0,0 @@
import type { ApiKey, Credential, Deployment, Environment, Instance, InstanceAccess, Member, MemberGroup, Release } from './types'
export const mockEnvironments: Environment[] = [
{
id: 'env-default',
name: 'default',
namespace: 'default',
description: 'Default shared environment, provisioned by Helm',
mode: 'shared',
backend: 'k8s',
health: 'ready',
createdAt: '2026-03-02 10:11',
},
{
id: 'env-prod-isolated',
name: 'prod-isolated',
namespace: 'payments',
description: 'Isolated production environment for the Payments team',
mode: 'isolated',
backend: 'k8s',
health: 'ready',
createdAt: '2026-03-14 19:22',
},
{
id: 'env-qa-host',
name: 'qa-host',
namespace: '—',
description: 'Staging host pool used for smoke testing',
mode: 'shared',
backend: 'host',
health: 'degraded',
createdAt: '2026-02-08 16:40',
},
]
export const MOCK_APP_ID_SLOTS = [
'app-customer-support',
'app-payments-workflow',
'app-marketing-copy',
'app-onboarding-draft',
] as const
export const mockCredentials: Credential[] = [
{
id: 'cred-openai-prod',
name: 'openai-prod',
provider: 'OpenAI',
kind: 'model',
scope: 'Workspace scoped',
validated: true,
},
{
id: 'cred-openai-test',
name: 'openai-test',
provider: 'OpenAI',
kind: 'model',
scope: 'Workspace scoped',
validated: false,
},
{
id: 'cred-deepseek-prod',
name: 'deepseek-prod',
provider: 'DeepSeek',
kind: 'model',
scope: 'Workspace scoped',
validated: true,
},
{
id: 'cred-anthropic-prod',
name: 'anthropic-prod',
provider: 'Anthropic',
kind: 'model',
scope: 'Workspace scoped',
validated: true,
},
{
id: 'cred-gmail-key001',
name: 'gmail-key001',
provider: 'Gmail',
kind: 'plugin',
scope: 'Workspace scoped',
validated: true,
},
{
id: 'cred-notion-key001',
name: 'notion-key001',
provider: 'Notion',
kind: 'plugin',
scope: 'Workspace scoped',
validated: true,
},
]
const sampleYaml = (appName: string, releaseId: string) => `# Release: ${releaseId}
app:
name: ${appName}
mode: advanced-chat
model:
provider: openai
name: gpt-4o
parameters:
temperature: 0.2
top_p: 0.95
prompt: |
You are a helpful assistant for ${appName}.
Follow company guidelines strictly.
tools:
- code-interpreter
- knowledge-retrieval
runner:
replicas: 3
maxTokens: 16384
timeoutSeconds: 120
observability:
logLevel: info
tracing: true
`
export const mockReleases: Release[] = [
{
id: 'R-043',
appId: 'app-payments-workflow',
gateCommitId: 'a3716d90',
operator: 'byron',
createdAt: '2026-04-15 19:08',
description: 'current draft deploy',
yaml: sampleYaml('Payments Workflow', 'R-043'),
},
{
id: 'R-042',
appId: 'app-customer-support',
gateCommitId: '9f23a1d2',
operator: 'byron',
createdAt: '2026-04-15 18:32',
description: 'stable release',
yaml: sampleYaml('Customer Support Bot', 'R-042'),
},
{
id: 'R-041',
appId: 'app-marketing-copy',
gateCommitId: '7db24e51',
operator: 'alice',
createdAt: '2026-04-13 15:10',
description: 'deploy failed on qa',
yaml: sampleYaml('Marketing Copy Generator', 'R-041'),
},
{
id: 'R-040',
appId: 'app-marketing-copy',
gateCommitId: '58c10aee',
operator: 'alice',
createdAt: '2026-04-12 09:24',
description: 'last stable qa release',
yaml: sampleYaml('Marketing Copy Generator', 'R-040'),
},
{
id: 'R-037',
appId: 'app-customer-support',
gateCommitId: '810fd671',
operator: 'alice',
createdAt: '2026-04-11 10:02',
description: 'historic',
yaml: sampleYaml('Customer Support Bot', 'R-037'),
},
{
id: 'R-031',
appId: 'app-payments-workflow',
gateCommitId: '4ac82db1',
operator: 'alice',
createdAt: '2026-04-07 14:55',
description: 'initial deploy',
yaml: sampleYaml('Payments Workflow', 'R-031'),
},
]
export const mockInstances: Instance[] = [
{
id: 'instance-cs',
appId: 'app-customer-support',
name: 'Customer Support',
description: 'Frontline CS assistant',
createdAt: '2026-02-10 12:23',
},
{
id: 'instance-payments',
appId: 'app-payments-workflow',
name: 'Payments Orchestrator',
description: 'Payment intent processing',
createdAt: '2026-02-18 09:41',
},
{
id: 'instance-marketing',
appId: 'app-marketing-copy',
name: 'Marketing Copy',
description: 'Ad copy generator',
createdAt: '2026-03-04 14:02',
},
{
id: 'instance-onboarding-draft',
appId: 'app-onboarding-draft',
name: 'Onboarding Draft',
description: 'Draft assistant waiting for its first environment deployment',
createdAt: '2026-04-18 10:30',
},
]
export const mockDeployments: Deployment[] = [
{
id: 'dep-cs-default',
instanceId: 'instance-cs',
environmentId: 'env-default',
activeReleaseId: 'R-042',
status: 'ready',
replicas: 1,
runtimeNote: 'Loaded in memory',
credentials: [
{ provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-prod' },
{ provider: 'Gmail', kind: 'plugin', credentialId: 'cred-gmail-key001' },
],
envVariables: [
{ key: 'dbkey', value: 'xxxxx', type: 'secret' },
{ key: 'keyno', value: '14', type: 'string' },
],
createdAt: '2026-02-10 12:25',
},
{
id: 'dep-cs-prod',
instanceId: 'instance-cs',
environmentId: 'env-prod-isolated',
activeReleaseId: 'R-037',
status: 'ready',
replicas: 3,
runtimeNote: 'Loaded in memory',
credentials: [
{ provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-prod' },
],
envVariables: [],
createdAt: '2026-03-02 15:10',
},
{
id: 'dep-payments-default',
instanceId: 'instance-payments',
environmentId: 'env-default',
activeReleaseId: 'R-031',
status: 'ready',
replicas: 1,
runtimeNote: 'Loaded in memory',
credentials: [
{ provider: 'Anthropic', kind: 'model', credentialId: 'cred-anthropic-prod' },
],
envVariables: [],
createdAt: '2026-04-07 15:00',
},
{
id: 'dep-payments-prod',
instanceId: 'instance-payments',
environmentId: 'env-prod-isolated',
activeReleaseId: 'R-031',
targetReleaseId: 'R-043',
status: 'deploying',
replicas: 3,
runtimeNote: 'Replicas 3 / Runtime Shell retained',
credentials: [
{ provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-prod' },
{ provider: 'DeepSeek', kind: 'model', credentialId: 'cred-deepseek-prod' },
{ provider: 'Gmail', kind: 'plugin', credentialId: 'cred-gmail-key001' },
{ provider: 'Notion', kind: 'plugin', credentialId: 'cred-notion-key001' },
],
envVariables: [
{ key: 'kn', value: 'this-is-kn-value', type: 'string' },
{ key: 'dbkey', value: 'xxxxx', type: 'secret' },
],
createdAt: '2026-04-15 19:08',
},
{
id: 'dep-marketing-qa',
instanceId: 'instance-marketing',
environmentId: 'env-qa-host',
activeReleaseId: 'R-040',
failedReleaseId: 'R-041',
status: 'deploy_failed',
errorMessage: 'Credential validate failed for openai-test',
runtimeNote: 'AppRunner Daemon Mode',
credentials: [
{ provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-test' },
],
envVariables: [],
createdAt: '2026-04-13 15:10',
},
]
export const mockApiKeys: ApiKey[] = [
{
id: 'apikey-cs-default',
instanceId: 'instance-cs',
environmentId: 'env-default',
label: 'default-key-001',
value: 'app-cs-default-b1c72a8f9d',
createdAt: '2026-02-10 12:25',
},
{
id: 'apikey-cs-prod',
instanceId: 'instance-cs',
environmentId: 'env-prod-isolated',
label: 'prod-key-001',
value: 'app-cs-prod-8a31f22d7c',
createdAt: '2026-03-02 15:11',
},
{
id: 'apikey-payments-default',
instanceId: 'instance-payments',
environmentId: 'env-default',
label: 'default-key-001',
value: 'app-pay-default-4c91a7e03b',
createdAt: '2026-04-07 15:01',
},
{
id: 'apikey-payments-prod',
instanceId: 'instance-payments',
environmentId: 'env-prod-isolated',
label: 'prod-key-001',
value: 'app-pay-prod-de1f5b8a62',
createdAt: '2026-04-15 19:10',
},
{
id: 'apikey-marketing-qa',
instanceId: 'instance-marketing',
environmentId: 'env-qa-host',
label: 'qa-key-001',
value: 'app-mk-qa-91ab2c3de4',
createdAt: '2026-04-13 15:12',
},
]
export const mockMembers: Member[] = [
{ id: 'mem-ava', name: 'Ava Chen', email: 'ava.chen@dify.ai' },
{ id: 'mem-lucas', name: 'Lucas Martin', email: 'lucas.martin@dify.ai' },
{ id: 'mem-rin', name: 'Rin Tanaka', email: 'rin.tanaka@dify.ai' },
{ id: 'mem-owen', name: 'Owen Walker', email: 'owen.walker@dify.ai' },
{ id: 'mem-noa', name: 'Noa Baker', email: 'noa.baker@dify.ai' },
{ id: 'mem-harper', name: 'Harper Young', email: 'harper.young@dify.ai' },
{ id: 'mem-ellis', name: 'Ellis Park', email: 'ellis.park@dify.ai' },
{ id: 'mem-zane', name: 'Zane Okafor', email: 'zane.okafor@dify.ai' },
{ id: 'mem-iris', name: 'Iris Novak', email: 'iris.novak@dify.ai' },
{ id: 'mem-mia', name: 'Mia Delgado', email: 'mia.delgado@dify.ai' },
{ id: 'mem-kai', name: 'Kai Andersson', email: 'kai.andersson@dify.ai' },
{ id: 'mem-ren', name: 'Ren Fujimoto', email: 'ren.fujimoto@dify.ai' },
]
export const mockMemberGroups: MemberGroup[] = [
{ id: 'group-engineering', name: 'Engineering', memberCount: 85, description: 'Platform, backend and infra engineers' },
{ id: 'group-support', name: 'Customer Success', memberCount: 118, description: 'Tier 1 and Tier 2 customer support' },
{ id: 'group-design', name: 'Design', memberCount: 14, description: 'Product and brand designers' },
{ id: 'group-ops', name: 'Operations', memberCount: 9, description: 'Admins and workspace operators' },
]
export const mockAccess: InstanceAccess[] = [
{
instanceId: 'instance-cs',
enabled: { api: true, runAccess: true },
webappUrl: 'https://my.webapp.com/afc28cef',
mcpUrl: 'https://mcp.dify.internal/instance-cs',
envPermissions: [
{ environmentId: 'env-prod-isolated', kind: 'organization' },
{
environmentId: 'env-default',
kind: 'specific',
memberIds: ['mem-ava', 'mem-lucas', 'mem-rin'],
groupIds: ['group-engineering', 'group-support'],
},
{
environmentId: 'env-testing',
kind: 'specific',
memberIds: ['mem-owen'],
groupIds: [],
},
],
},
{
instanceId: 'instance-payments',
enabled: { api: true, runAccess: false },
webappUrl: 'https://my.webapp.com/payments',
mcpUrl: 'https://mcp.dify.internal/instance-payments',
envPermissions: [
{
environmentId: 'env-prod-isolated',
kind: 'specific',
memberIds: ['mem-noa', 'mem-harper', 'mem-ellis'],
groupIds: ['group-ops'],
},
{ environmentId: 'env-default', kind: 'organization' },
],
},
{
instanceId: 'instance-marketing',
enabled: { api: true, runAccess: true },
webappUrl: 'https://my.webapp.com/marketing',
envPermissions: [
{ environmentId: 'env-default', kind: 'anyone' },
],
},
{
instanceId: 'instance-onboarding-draft',
enabled: { api: false, runAccess: false },
envPermissions: [],
},
]

View File

@ -11,6 +11,14 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
activeRelease,
deployedRows,
environmentId,
environmentName,
releaseCommit,
releaseLabel,
} from './api-utils'
import { useDeploymentsStore } from './store'
import { useSourceApps } from './use-source-apps'
@ -26,25 +34,26 @@ const InfoRow: FC<{ label: string, value: string }> = ({ label, value }) => {
const RollbackModal: FC = () => {
const { t } = useTranslation('deployments')
const modal = useDeploymentsStore(state => state.rollbackModal)
const deployments = useDeploymentsStore(state => state.deployments)
const instances = useDeploymentsStore(state => state.instances)
const releases = useDeploymentsStore(state => state.releases)
const environments = useDeploymentsStore(state => state.environments)
const appData = useDeploymentsStore(state => modal.appId ? state.appData[modal.appId] : undefined)
const closeRollbackModal = useDeploymentsStore(state => state.closeRollbackModal)
const rollbackDeployment = useDeploymentsStore(state => state.rollbackDeployment)
const { appMap } = useSourceApps()
const deployment = deployments.find(d => d.id === modal.deploymentId)
const targetRelease = releases.find(r => r.id === modal.targetReleaseId)
const currentRelease = releases.find(r => r.id === deployment?.activeReleaseId)
const environment = environments.find(env => env.id === deployment?.environmentId)
const instance = instances.find(i => i.id === deployment?.instanceId)
const app = instance ? appMap.get(instance.appId) : undefined
const currentRow = deployedRows(appData?.environmentDeployments.environmentDeployments)
.find(row => environmentId(row.environment) === modal.environmentId)
const targetRelease = [
...(appData?.candidates.releases ?? []),
...(appData?.releaseHistory.data?.map(row => row.release).filter(release => !!release) ?? []),
].find(release => release?.id === modal.targetReleaseId)
const currentRelease = activeRelease(currentRow)
const environment = currentRow?.environment
?? appData?.candidates.environmentOptions?.find(env => env.id === modal.environmentId)
const app = modal.appId ? appMap.get(modal.appId) : undefined
const confirm = () => {
if (!modal.deploymentId || !modal.targetReleaseId)
if (!modal.appId || !modal.environmentId || !modal.targetReleaseId)
return
rollbackDeployment(modal.deploymentId, modal.targetReleaseId)
rollbackDeployment(modal.appId, modal.environmentId, modal.targetReleaseId)
}
return (
@ -55,23 +64,23 @@ const RollbackModal: FC = () => {
<AlertDialogContent className="w-[520px]">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('rollback.title', { release: targetRelease?.id ?? '-' })}
{t('rollback.title', { release: releaseLabel(targetRelease) })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t('rollback.description')}
</AlertDialogDescription>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
<InfoRow label={t('rollback.instance')} value={instance?.name ?? '-'} />
<InfoRow label={t('rollback.instance')} value={app?.name ?? '-'} />
<InfoRow label={t('rollback.sourceApp')} value={app?.name ?? '-'} />
<InfoRow label={t('rollback.environment')} value={environment?.name ?? '-'} />
<InfoRow label={t('rollback.environment')} value={environmentName(environment)} />
<InfoRow
label={t('rollback.currentRelease')}
value={currentRelease ? `${currentRelease.id} / ${currentRelease.gateCommitId}` : '-'}
value={currentRelease ? `${releaseLabel(currentRelease)} / ${releaseCommit(currentRelease)}` : '-'}
/>
<InfoRow
label={t('rollback.rollbackTo')}
value={targetRelease ? `${targetRelease.id} / ${targetRelease.gateCommitId}` : '-'}
value={targetRelease ? `${releaseLabel(targetRelease)} / ${releaseCommit(targetRelease)}` : '-'}
/>
</div>

View File

@ -1,56 +1,43 @@
import type { AccessMethod, AccessPermissionKind, ApiKey, AppInfo, CredentialBinding, Deployment, Environment, EnvVariable, Instance, InstanceAccess, Release } from './types'
import type { AppInfo } from './types'
import type { APIToken, BindingsProto } from '@/contract/console/deployments'
import type { DeploymentAppData } from '@/service/deployments'
import { create } from 'zustand'
import { MOCK_APP_ID_SLOTS, mockAccess, mockApiKeys, mockDeployments, mockEnvironments, mockInstances, mockReleases } from './mock-data'
const DEPLOY_MOCK_DURATION_MS = 2000
let releaseCounter = 44
let apiKeyCounter = 100
let instanceCounter = 100
function generateReleaseId() {
const id = `R-0${releaseCounter}`
releaseCounter += 1
return id
}
function generateApiKeyId() {
const id = `apikey-${apiKeyCounter}`
apiKeyCounter += 1
return id
}
function generateInstanceId() {
const id = `instance-new-${instanceCounter}`
instanceCounter += 1
return id
}
function randomGateCommitId() {
return Math.random().toString(16).slice(2, 10)
}
function nowStamp() {
return new Date().toISOString().replace('T', ' ').slice(0, 16)
}
import {
cancelDeployment,
createApiKey,
createDeployment,
deleteApiKey,
fetchDeploymentAppData,
patchAccessChannel,
rollbackEnvironment,
undeployEnvironment,
updateEnvironmentAccessPolicy,
} from '@/service/deployments'
export type StartDeployParams = {
instanceId: string
appId: string
environmentId: string
releaseId?: string
releaseNote?: string
credentials: CredentialBinding[]
envVariables: EnvVariable[]
bindings?: BindingsProto
}
type OpenDeployDrawerParams = {
instanceId: string
appId: string
environmentId?: string
releaseId?: string
}
type OpenRollbackParams = {
deploymentId: string
appId: string
environmentId: string
targetReleaseId: string
deploymentId?: string
}
type CreatedApiToken = Pick<APIToken, 'id' | 'environmentId' | 'maskedPrefix' | 'name'> & {
appId: string
token: string
}
export type CreateInstanceParams = {
@ -60,22 +47,20 @@ export type CreateInstanceParams = {
}
type DeploymentsState = {
environments: Environment[]
instances: Instance[]
deployments: Deployment[]
releases: Release[]
apiKeys: ApiKey[]
access: InstanceAccess[]
seededAppIds: string[] | null
sourceApps: AppInfo[]
appData: Record<string, DeploymentAppData>
createdApiToken?: CreatedApiToken
deployDrawer: {
open: boolean
instanceId?: string
appId?: string
environmentId?: string
releaseId?: string
}
rollbackModal: {
open: boolean
appId?: string
environmentId?: string
deploymentId?: string
targetReleaseId?: string
}
@ -91,40 +76,37 @@ type DeploymentsState = {
closeCreateInstanceModal: () => void
seedInstancesFromApps: (apps: AppInfo[]) => void
applyAppData: (data: DeploymentAppData) => void
refreshAppData: (appId: string) => Promise<void>
createInstance: (params: CreateInstanceParams) => string
updateInstance: (instanceId: string, patch: Partial<Pick<Instance, 'name' | 'description'>>) => void
switchSourceApp: (instanceId: string, appId: string) => void
deleteInstance: (instanceId: string) => void
updateInstance: (appId: string, patch: Partial<Pick<AppInfo, 'name' | 'description'>>) => void
switchSourceApp: (appId: string, nextAppId: string) => void
deleteInstance: (appId: string) => void
startDeploy: (params: StartDeployParams) => void
retryDeploy: (deploymentId: string) => void
rollbackDeployment: (deploymentId: string, targetReleaseId: string) => void
undeployDeployment: (deploymentId: string) => void
startDeploy: (params: StartDeployParams) => Promise<void>
retryDeploy: (appId: string, environmentId: string, targetReleaseId: string) => Promise<void>
rollbackDeployment: (appId: string, environmentId: string, targetReleaseId: string) => Promise<void>
undeployDeployment: (appId: string, environmentId: string, deploymentId?: string, isDeploying?: boolean) => Promise<void>
generateApiKey: (instanceId: string, environmentId: string) => void
revokeApiKey: (apiKeyId: string) => void
toggleAccessMethod: (instanceId: string, method: AccessMethod, enabled: boolean) => void
setEnvAccessPermission: (instanceId: string, environmentId: string, kind: AccessPermissionKind) => void
setEnvAccessMembers: (
instanceId: string,
generateApiKey: (appId: string, environmentId: string) => Promise<void>
revokeApiKey: (appId: string, environmentId: string, apiKeyId: string) => Promise<void>
clearCreatedApiToken: () => void
toggleAccessChannel: (appId: string, channel: string, enabled: boolean, expectedVersion: number) => Promise<void>
setEnvironmentAccessPolicy: (
appId: string,
environmentId: string,
members: { memberIds: string[], groupIds: string[] },
) => void
}
function updateDeployment(deployments: Deployment[], deploymentId: string, patch: Partial<Deployment>): Deployment[] {
return deployments.map(item => item.id === deploymentId ? { ...item, ...patch } : item)
channel: string,
enabled: boolean,
accessMode: string,
expectedVersion: number,
) => Promise<void>
}
export const useDeploymentsStore = create<DeploymentsState>((set, get) => ({
environments: mockEnvironments,
instances: mockInstances,
deployments: mockDeployments,
releases: mockReleases,
apiKeys: mockApiKeys,
access: mockAccess,
seededAppIds: null,
sourceApps: [],
appData: {},
createdApiToken: undefined,
deployDrawer: { open: false },
rollbackModal: { open: false },
@ -133,299 +115,128 @@ export const useDeploymentsStore = create<DeploymentsState>((set, get) => ({
openDeployDrawer: params => set({
deployDrawer: {
open: true,
instanceId: params.instanceId,
appId: params.appId,
environmentId: params.environmentId,
releaseId: params.releaseId,
},
}),
closeDeployDrawer: () => set({ deployDrawer: { open: false } }),
openRollbackModal: ({ deploymentId, targetReleaseId }) => set({
rollbackModal: { open: true, deploymentId, targetReleaseId },
openRollbackModal: ({ appId, environmentId, deploymentId, targetReleaseId }) => set({
rollbackModal: { open: true, appId, environmentId, deploymentId, targetReleaseId },
}),
closeRollbackModal: () => set({ rollbackModal: { open: false } }),
openCreateInstanceModal: () => set({ createInstanceModal: { open: true } }),
closeCreateInstanceModal: () => set({ createInstanceModal: { open: false } }),
seedInstancesFromApps: (apps) => {
if (apps.length === 0)
return
const realIds = apps.map(a => a.id)
const previous = get().seededAppIds
const unchanged
= previous !== null
&& previous.length === realIds.length
&& previous.every((id, i) => id === realIds[i])
if (unchanged)
return
seedInstancesFromApps: apps => set(state => ({
sourceApps: apps,
appData: Object.fromEntries(
Object.entries(state.appData).filter(([appId]) => apps.some(app => app.id === appId)),
),
})),
const slotMap: Record<string, string> = {}
MOCK_APP_ID_SLOTS.forEach((mockId, idx) => {
const real = apps[idx % apps.length]!
slotMap[mockId] = real.id
})
applyAppData: data => set(state => ({
appData: {
...state.appData,
[data.appId]: data,
},
})),
refreshAppData: async (appId) => {
const data = await fetchDeploymentAppData(appId)
get().applyAppData(data)
},
createInstance: ({ appId }) => {
set({ createInstanceModal: { open: false } })
return appId
},
updateInstance: (appId, patch) => {
set(state => ({
instances: state.instances.map((i) => {
const bindingProfileId = i.bindingProfileId ?? i.appId
return {
...i,
appId: slotMap[bindingProfileId] ?? i.appId,
bindingProfileId,
}
}),
releases: state.releases.map(r => ({
...r,
appId: slotMap[r.appId] ?? r.appId,
})),
seededAppIds: realIds,
sourceApps: state.sourceApps.map(app => app.id === appId ? { ...app, ...patch } : app),
}))
},
createInstance: ({ appId, name, description }) => {
const id = generateInstanceId()
const instance: Instance = {
id,
appId,
name,
description,
createdAt: nowStamp(),
}
switchSourceApp: () => undefined,
deleteInstance: (appId) => {
set(state => ({
instances: [...state.instances, instance],
access: [
...state.access,
{
instanceId: id,
enabled: { api: true, runAccess: true },
envPermissions: [],
sourceApps: state.sourceApps.filter(app => app.id !== appId),
createdApiToken: state.createdApiToken?.appId === appId ? undefined : state.createdApiToken,
appData: Object.fromEntries(
Object.entries(state.appData).filter(([key]) => key !== appId),
),
}))
},
startDeploy: async ({ appId, environmentId, releaseId, releaseNote, bindings }) => {
set({ deployDrawer: { open: false } })
await createDeployment({ appId, environmentId, releaseId, releaseNote, bindings })
await get().refreshAppData(appId)
},
retryDeploy: async (appId, environmentId, targetReleaseId) => {
await rollbackEnvironment(appId, environmentId, targetReleaseId)
await get().refreshAppData(appId)
},
rollbackDeployment: async (appId, environmentId, targetReleaseId) => {
set({ rollbackModal: { open: false } })
await rollbackEnvironment(appId, environmentId, targetReleaseId)
await get().refreshAppData(appId)
},
undeployDeployment: async (appId, environmentId, deploymentId, isDeploying) => {
if (isDeploying && deploymentId)
await cancelDeployment(appId, environmentId, deploymentId)
else
await undeployEnvironment(appId, environmentId)
await get().refreshAppData(appId)
},
generateApiKey: async (appId, environmentId) => {
const appData = get().appData[appId]
const existingCount = appData?.accessConfig.developerApi?.apiKeys?.filter(key => key.environmentId === environmentId).length ?? 0
const environmentName = appData
?.environmentDeployments
.environmentDeployments
?.find(row => row.environment?.id === environmentId)
?.environment
?.name ?? 'env'
const label = `${environmentName}-key-${String(existingCount + 1).padStart(3, '0')}`
const response = await createApiKey(appId, environmentId, label)
await get().refreshAppData(appId)
if (response.apiToken?.token) {
set({
createdApiToken: {
id: response.apiToken.id,
appId,
environmentId,
maskedPrefix: response.apiToken.maskedPrefix,
name: response.apiToken.name || label,
token: response.apiToken.token,
},
],
createInstanceModal: { open: false },
}))
return id
},
updateInstance: (instanceId, patch) => {
set(state => ({
instances: state.instances.map(item => item.id === instanceId ? { ...item, ...patch } : item),
}))
},
switchSourceApp: (instanceId, appId) => {
set(state => ({
instances: state.instances.map(item => item.id === instanceId ? { ...item, appId, bindingProfileId: appId } : item),
}))
},
deleteInstance: (instanceId) => {
set(state => ({
instances: state.instances.filter(item => item.id !== instanceId),
deployments: state.deployments.filter(d => d.instanceId !== instanceId),
apiKeys: state.apiKeys.filter(k => k.instanceId !== instanceId),
access: state.access.filter(a => a.instanceId !== instanceId),
}))
},
startDeploy: ({ instanceId, environmentId, releaseId, releaseNote, credentials, envVariables }) => {
const instance = get().instances.find(i => i.id === instanceId)
if (!instance)
return
let targetReleaseId = releaseId
let newRelease: Release | undefined
if (!targetReleaseId) {
const newReleaseId = generateReleaseId()
const trimmedNote = releaseNote?.trim()
newRelease = {
id: newReleaseId,
appId: instance.appId,
gateCommitId: randomGateCommitId(),
operator: 'you',
createdAt: nowStamp(),
description: trimmedNote || 'draft deploy',
yaml: `# Release: ${newReleaseId}\napp:\n name: ${instance.appId}\n mode: advanced-chat\n`,
}
targetReleaseId = newReleaseId
}
const existing = get().deployments.find(d => d.instanceId === instanceId && d.environmentId === environmentId)
let nextDeployments: Deployment[]
let targetDeploymentId: string
if (existing) {
targetDeploymentId = existing.id
nextDeployments = updateDeployment(get().deployments, existing.id, {
status: 'deploying',
targetReleaseId,
failedReleaseId: undefined,
credentials,
envVariables,
errorMessage: undefined,
})
}
else {
targetDeploymentId = `dep-${instanceId}-${environmentId}-${Date.now()}`
const newDeployment: Deployment = {
id: targetDeploymentId,
instanceId,
environmentId,
activeReleaseId: targetReleaseId,
targetReleaseId,
status: 'deploying',
runtimeNote: 'Loading...',
credentials,
envVariables,
createdAt: nowStamp(),
}
nextDeployments = [...get().deployments, newDeployment]
}
set(state => ({
deployments: nextDeployments,
releases: newRelease ? [newRelease, ...state.releases] : state.releases,
deployDrawer: { open: false },
}))
setTimeout(() => {
set(state => ({
deployments: updateDeployment(state.deployments, targetDeploymentId, {
activeReleaseId: targetReleaseId,
targetReleaseId: undefined,
failedReleaseId: undefined,
status: 'ready',
runtimeNote: 'Loaded in memory',
}),
}))
}, DEPLOY_MOCK_DURATION_MS)
},
retryDeploy: (deploymentId) => {
const deployment = get().deployments.find(d => d.id === deploymentId)
if (!deployment)
return
const targetReleaseId = deployment.failedReleaseId ?? deployment.targetReleaseId ?? deployment.activeReleaseId
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
status: 'deploying',
targetReleaseId,
failedReleaseId: undefined,
errorMessage: undefined,
}),
}))
setTimeout(() => {
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
activeReleaseId: targetReleaseId,
targetReleaseId: undefined,
status: 'ready',
runtimeNote: 'Loaded in memory',
}),
}))
}, DEPLOY_MOCK_DURATION_MS)
revokeApiKey: async (appId, environmentId, apiKeyId) => {
await deleteApiKey(appId, environmentId, apiKeyId)
await get().refreshAppData(appId)
},
rollbackDeployment: (deploymentId, targetReleaseId) => {
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
status: 'deploying',
targetReleaseId,
failedReleaseId: undefined,
errorMessage: undefined,
}),
rollbackModal: { open: false },
}))
setTimeout(() => {
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
activeReleaseId: targetReleaseId,
targetReleaseId: undefined,
status: 'ready',
runtimeNote: 'Loaded in memory',
}),
}))
}, DEPLOY_MOCK_DURATION_MS)
clearCreatedApiToken: () => set({ createdApiToken: undefined }),
toggleAccessChannel: async (appId, channel, enabled, expectedVersion) => {
await patchAccessChannel(appId, channel, enabled, expectedVersion)
await get().refreshAppData(appId)
},
undeployDeployment: (deploymentId) => {
set(state => ({
deployments: state.deployments.filter(d => d.id !== deploymentId),
}))
},
generateApiKey: (instanceId, environmentId) => {
const existingCount = get().apiKeys.filter(k => k.instanceId === instanceId && k.environmentId === environmentId).length
const env = get().environments.find(e => e.id === environmentId)
const labelPrefix = env?.name ?? 'env'
const label = `${labelPrefix}-key-${String(existingCount + 1).padStart(3, '0')}`
const suffix = Math.random().toString(16).slice(2, 12)
const newKey: ApiKey = {
id: generateApiKeyId(),
instanceId,
environmentId,
label,
value: `app-${instanceId.slice(-4)}-${suffix}`,
createdAt: nowStamp(),
}
set(state => ({ apiKeys: [newKey, ...state.apiKeys] }))
},
revokeApiKey: (apiKeyId) => {
set(state => ({
apiKeys: state.apiKeys.filter(k => k.id !== apiKeyId),
}))
},
toggleAccessMethod: (instanceId, method, enabled) => {
set(state => ({
access: state.access.map((a) => {
if (a.instanceId !== instanceId)
return a
return { ...a, enabled: { ...a.enabled, [method]: enabled } }
}),
}))
},
setEnvAccessPermission: (instanceId, environmentId, kind) => {
set(state => ({
access: state.access.map((a) => {
if (a.instanceId !== instanceId)
return a
const existingIdx = a.envPermissions.findIndex(p => p.environmentId === environmentId)
const existing = existingIdx >= 0 ? a.envPermissions[existingIdx] : undefined
const nextEntry = kind === 'specific'
? {
environmentId,
kind,
memberIds: existing?.memberIds ?? [],
groupIds: existing?.groupIds ?? [],
}
: { environmentId, kind }
const envPermissions = existingIdx >= 0
? a.envPermissions.map((p, i) => (i === existingIdx ? nextEntry : p))
: [...a.envPermissions, nextEntry]
return { ...a, envPermissions }
}),
}))
},
setEnvAccessMembers: (instanceId, environmentId, { memberIds, groupIds }) => {
set(state => ({
access: state.access.map((a) => {
if (a.instanceId !== instanceId)
return a
const existingIdx = a.envPermissions.findIndex(p => p.environmentId === environmentId)
const nextEntry = {
environmentId,
kind: 'specific' as AccessPermissionKind,
memberIds,
groupIds,
}
const envPermissions = existingIdx >= 0
? a.envPermissions.map((p, i) => (i === existingIdx ? nextEntry : p))
: [...a.envPermissions, nextEntry]
return { ...a, envPermissions }
}),
}))
setEnvironmentAccessPolicy: async (appId, environmentId, channel, enabled, accessMode, expectedVersion) => {
await updateEnvironmentAccessPolicy(appId, environmentId, channel, enabled, accessMode, [], expectedVersion)
await get().refreshAppData(appId)
},
}))

View File

@ -1,54 +1,11 @@
export type EnvironmentMode = 'shared' | 'isolated'
export type EnvironmentBackend = 'k8s' | 'host'
export type EnvironmentHealth = 'ready' | 'degraded'
export type DeployStatus = 'ready' | 'deploying' | 'deploy_failed'
export type AppMode = 'chat' | 'agent-chat' | 'workflow' | 'completion' | 'advanced-chat'
export type AccessMethod = 'api' | 'runAccess'
export type AccessPermissionKind = 'organization' | 'specific' | 'external' | 'anyone'
export type EnvAccessPermission = {
environmentId: string
kind: AccessPermissionKind
memberIds?: string[]
groupIds?: string[]
}
export type Member = {
id: string
name: string
email: string
}
export type MemberGroup = {
id: string
name: string
memberCount: number
description?: string
}
export type Environment = {
id: string
name: string
namespace: string
description?: string
mode: EnvironmentMode
backend: EnvironmentBackend
health: EnvironmentHealth
createdAt: string
}
export type Credential = {
id: string
name: string
provider: string
kind: 'model' | 'plugin'
scope: string
validated: boolean
}
export type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
export type AppInfo = {
id: string
@ -60,67 +17,3 @@ export type AppInfo = {
iconUrl?: string | null
description?: string
}
export type Release = {
id: string
appId: string
gateCommitId: string
operator: string
createdAt: string
description?: string
yaml: string
}
export type CredentialBinding = {
provider: string
kind: 'model' | 'plugin'
credentialId?: string
}
export type EnvVariable = {
key: string
value: string
type: 'string' | 'secret'
}
export type Deployment = {
id: string
instanceId: string
environmentId: string
activeReleaseId: string
targetReleaseId?: string
failedReleaseId?: string
status: DeployStatus
replicas?: number
errorMessage?: string
runtimeNote?: string
credentials: CredentialBinding[]
envVariables: EnvVariable[]
createdAt: string
}
export type Instance = {
id: string
appId: string
bindingProfileId?: string | undefined
name: string
description?: string
createdAt: string
}
export type ApiKey = {
id: string
instanceId: string
environmentId: string
label: string
value: string
createdAt: string
}
export type InstanceAccess = {
instanceId: string
enabled: Record<AccessMethod, boolean>
webappUrl?: string
mcpUrl?: string
envPermissions: EnvAccessPermission[]
}

View File

@ -0,0 +1,42 @@
'use client'
import type { AppInfo } from './types'
import { useQueries } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import { fetchDeploymentAppData } from '@/service/deployments'
import { useDeploymentsStore } from './store'
type UseDeploymentDataOptions = {
enabled?: boolean
}
export function useDeploymentData(apps: AppInfo[], options: UseDeploymentDataOptions = {}) {
const { enabled = true } = options
const applyAppData = useDeploymentsStore(state => state.applyAppData)
const queries = useQueries({
queries: apps.map(app => ({
queryKey: ['deployments', 'app-data', app.id],
queryFn: () => fetchDeploymentAppData(app.id),
enabled: enabled && Boolean(app.id),
staleTime: 30 * 1000,
})),
})
const queriesRef = useRef(queries)
queriesRef.current = queries
const dataUpdatedAt = queries.map(query => query.dataUpdatedAt).join('|')
useEffect(() => {
queriesRef.current.forEach((query) => {
if (query.data)
applyAppData(query.data)
})
}, [applyAppData, dataUpdatedAt])
return {
isLoading: queries.some(query => query.isLoading),
isFetching: queries.some(query => query.isFetching),
isError: queries.some(query => query.isError),
}
}

View File

@ -4,6 +4,7 @@ import type { App } from '@/types/app'
import { useEffect, useMemo } from 'react'
import { useAppList } from '@/service/use-apps'
import { useDeploymentsStore } from './store'
import { useDeploymentData } from './use-deployment-data'
const MAX_SOURCE_APPS = 100
@ -46,12 +47,14 @@ export function useSourceApps(options: UseSourceAppsOptions = {}) {
seedInstancesFromApps(apps)
}, [apps, seedInstancesFromApps])
const deploymentData = useDeploymentData(apps, { enabled: enabled && apps.length > 0 })
return {
apps,
appMap,
isLoading,
isFetching,
isError,
isLoading: isLoading || deploymentData.isLoading,
isFetching: isFetching || deploymentData.isFetching,
isError: isError || deploymentData.isError,
isEmpty: !isLoading && apps.length === 0,
}
}

View File

@ -17,28 +17,31 @@ const DeploymentsNav = () => {
const params = useParams<{ instanceId?: string }>()
const instanceId = params?.instanceId
const instances = useDeploymentsStore(state => state.instances)
const sourceApps = useDeploymentsStore(state => state.sourceApps)
const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal)
const { appMap } = useSourceApps({ enabled: isActive })
const apps = useMemo(
() => sourceApps.length > 0 ? sourceApps : [...appMap.values()],
[appMap, sourceApps],
)
const navigationItems = useMemo<NavItem[]>(() => {
if (!isActive)
return []
return instances.map((instance) => {
const app = appMap.get(instance.appId)
return apps.map((app) => {
return {
id: instance.id,
name: instance.name,
link: `/deployments/${instance.id}/overview`,
icon_type: (app?.iconType ?? null) as AppIconType | null,
icon: app?.icon ?? '',
icon_background: app?.iconBackground ?? null,
icon_url: app?.iconUrl ?? null,
mode: app?.mode as unknown as AppModeEnum | undefined,
id: app.id,
name: app.name,
link: `/deployments/${app.id}/overview`,
icon_type: (app.iconType ?? null) as AppIconType | null,
icon: app.icon ?? '',
icon_background: app.iconBackground ?? null,
icon_url: app.iconUrl ?? null,
mode: app.mode as unknown as AppModeEnum | undefined,
}
})
}, [instances, appMap, isActive])
}, [apps, isActive])
const curNav = useMemo(() => {
if (!instanceId)

View File

@ -0,0 +1,703 @@
import { type } from '@orpc/contract'
import { base } from '../base'
type Timestamp = string
export type ConsoleAppSummary = {
id?: string
name?: string
description?: string
icon?: string
mode?: string
status?: string
createdAt?: Timestamp
}
export type ConsoleEnvironmentSummary = {
id?: string
name?: string
description?: string
runtime?: string
type?: string
status?: string
tags?: string[]
}
export type ConsoleReleaseSummary = {
id?: string
displayId?: string
status?: string
description?: string
commitId?: string
createdAt?: Timestamp
name?: string
}
export type LastErrorProto = {
phase?: string
code?: string
message?: string
releaseId?: string
}
export type ConsoleInstanceSummary = {
id?: string
replicas?: number
status?: string
desiredReleaseId?: string
desiredReleaseDisplayId?: string
observedReleaseId?: string
observedReleaseDisplayId?: string
currentDeploymentId?: string
lastDeployedAt?: Timestamp
lastReadyAt?: Timestamp
lastError?: LastErrorProto
}
export type ConsoleActions = {
canDeploy?: boolean
canDeployAnotherRelease?: boolean
canCancel?: boolean
canUndeploy?: boolean
canRollback?: boolean
canViewProgress?: boolean
canViewLogs?: boolean
disabledReason?: string
}
export type ConsoleWarning = {
code?: string
message?: string
}
export type DeploymentSummaryRow = {
environmentId?: string
environmentName?: string
releaseId?: string
releaseDisplayId?: string
status?: string
}
export type ChannelSummary = {
enabled?: boolean
}
export type AccessSummary = {
webapp?: ChannelSummary
cli?: ChannelSummary
api?: ChannelSummary
mcp?: ChannelSummary
}
export type GetDeploymentOverviewReply = {
app?: ConsoleAppSummary
deployments?: DeploymentSummaryRow[]
access?: AccessSummary
warnings?: ConsoleWarning[]
}
export type RuntimeBindingDisplay = {
slot?: string
displayName?: string
maskedValue?: string
}
export type RuntimeBindings = {
credentials?: RuntimeBindingDisplay[]
envVars?: RuntimeBindingDisplay[]
}
export type RuntimeEndpoints = {
run?: string
health?: string
}
export type ObservedRuntime = {
release?: ConsoleReleaseSummary
bindings?: RuntimeBindings
endpoints?: RuntimeEndpoints
}
export type PendingDeployment = {
deploymentId?: string
release?: ConsoleReleaseSummary
bindings?: RuntimeBindings
}
export type EnvironmentDeploymentRow = {
environment?: ConsoleEnvironmentSummary
instance?: ConsoleInstanceSummary
observedRuntime?: ObservedRuntime
pendingDeployment?: PendingDeployment
actions?: ConsoleActions
}
export type Pagination = {
totalCount?: number
perPage?: number
currentPage?: number
totalPages?: number
}
export type ListEnvironmentDeploymentsReply = {
environmentDeployments?: EnvironmentDeploymentRow[]
pagination?: Pagination
}
export type EnvironmentOption = {
id?: string
name?: string
type?: string
status?: string
description?: string
tags?: string[]
disabled?: boolean
disabledReason?: string
}
export type ListDeploymentCandidatesReply = {
defaultReleaseId?: string
releases?: ConsoleReleaseSummary[]
environmentOptions?: EnvironmentOption[]
}
export type CurrentInstanceState = {
instanceId?: string
status?: string
observedReleaseDisplayId?: string
}
export type ConsoleCredentialOption = {
id?: string
displayName?: string
pluginId?: string
provider?: string
}
export type ConsoleEnvVarOption = {
id?: string
name?: string
maskedValue?: string
valueType?: string
version?: number
}
export type DeploymentSlot = {
kind?: string
slot?: string
label?: string
required?: boolean
selectedCredentialId?: string
selectedEnvVarId?: string
credentialOptions?: ConsoleCredentialOption[]
envVarOptions?: ConsoleEnvVarOption[]
missing?: boolean
missingReason?: string
}
export type DeploymentBlocker = {
code?: string
message?: string
}
export type GetDeploymentPlanReply = {
release?: ConsoleReleaseSummary
environment?: ConsoleEnvironmentSummary
currentInstance?: CurrentInstanceState
slots?: DeploymentSlot[]
canDeploy?: boolean
blockers?: DeploymentBlocker[]
}
export type UserDisplay = {
id?: string
displayName?: string
}
export type DeployedToSummary = {
environmentId?: string
environmentName?: string
instanceStatus?: string
}
export type ReleaseHistoryActions = {
canDeploy?: boolean
canViewDetail?: boolean
canDelete?: boolean
}
export type ReleaseHistoryRow = {
release?: ConsoleReleaseSummary
createdBy?: UserDisplay
deployedTo?: DeployedToSummary[]
actions?: ReleaseHistoryActions
}
export type ListReleaseHistoryReply = {
data?: ReleaseHistoryRow[]
pagination?: Pagination
}
export type EffectivePolicySummary = {
channel?: string
enabled?: boolean
accessMode?: string
label?: string
subjectCount?: number
version?: number
}
export type EnvironmentPolicySummary = {
environment?: ConsoleEnvironmentSummary
effectivePolicy?: EffectivePolicySummary
}
export type UserAccessSummary = {
sharedChannels?: string[]
environmentPolicies?: EnvironmentPolicySummary[]
}
export type WebAppAccessRow = {
environment?: ConsoleEnvironmentSummary
url?: string
publicCode?: string
canCopy?: boolean
canShowQrCode?: boolean
canRegenerate?: boolean
createNeeded?: boolean
}
export type WebAppAccessSummary = {
supported?: boolean
enabled?: boolean
rows?: WebAppAccessRow[]
}
export type UnsupportedChannelSummary = {
supported?: boolean
statusLabel?: string
}
export type CliAccessSummary = {
supported?: boolean
enabled?: boolean
statusLabel?: string
url?: string
}
export type DeveloperAPIKeySummary = {
id?: string
environmentId?: string
environmentName?: string
name?: string
maskedPrefix?: string
createdAt?: Timestamp
}
export type DeveloperAPISummary = {
enabled?: boolean
apiKeys?: DeveloperAPIKeySummary[]
}
export type GetAccessConfigReply = {
userAccess?: UserAccessSummary
webapp?: WebAppAccessSummary
mcp?: UnsupportedChannelSummary
cli?: CliAccessSummary
developerApi?: DeveloperAPISummary
}
export type AccessSubjectDisplay = {
id?: string
subjectType?: string
name?: string
avatarUrl?: string
memberCount?: number
}
export type AccessPolicyOption = {
mode?: string
label?: string
selected?: boolean
disabled?: boolean
groups?: AccessSubjectDisplay[]
members?: AccessSubjectDisplay[]
}
export type AccessPolicyDetail = {
id?: string
channel?: string
enabled?: boolean
accessMode?: string
version?: number
options?: AccessPolicyOption[]
}
export type GetEnvironmentAccessPolicyReply = {
policy?: AccessPolicyDetail
}
export type AccessSubject = {
subjectId?: string
subjectType?: string
}
export type AccessPolicy = {
id?: string
appId?: string
environmentId?: string
scopeType?: string
channel?: string
enabled?: boolean
accessMode?: string
subjects?: AccessSubject[]
version?: number
subjectCount?: number
}
export type UpdateEnvironmentAccessPolicyReply = {
policy?: AccessPolicy
}
export type SearchAccessSubjectsReply = {
data?: AccessSubjectDisplay[]
}
export type PatchAccessChannelReply = {
policy?: EffectivePolicySummary
}
export type Release = {
id?: string
appId?: string
seq?: number
displayId?: string
status?: string
gateCommitId?: string
dslVersion?: string
description?: string
requiredPluginIds?: string[]
requiredModelSlots?: string[]
requiredEnvVarNames?: string[]
createdAt?: Timestamp
readyAt?: Timestamp
name?: string
}
export type CreateReleaseReply = {
release?: Release
}
export type CredentialBindingProto = {
slot?: string
credentialId?: string
}
export type EnvVarBindingProto = {
slot?: string
envVarId?: string
}
export type BindingsProto = {
models?: CredentialBindingProto[]
plugins?: CredentialBindingProto[]
envVars?: EnvVarBindingProto[]
}
export type CreateDeploymentReply = {
instanceId?: string
deploymentId?: string
status?: string
}
export type CancelDeploymentReply = {
status?: string
}
export type UndeployEnvironmentReply = {
deploymentId?: string
}
export type RollbackEnvironmentReply = {
deploymentId?: string
}
export type APIToken = {
id?: string
appId?: string
environmentId?: string
name?: string
token?: string
maskedPrefix?: string
createdAt?: Timestamp
lastUsedAt?: Timestamp
}
export type ListEnvironmentAPITokensReply = {
data?: APIToken[]
}
export type CreateEnvironmentAPITokenReply = {
apiToken?: APIToken
}
export type DeleteEnvironmentAPITokenReply = Record<string, never>
export const deploymentOverviewContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/overview',
method: 'GET',
})
.input(type<{ params: { appId: string } }>())
.output(type<GetDeploymentOverviewReply>())
export const environmentDeploymentsContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/environment-deployments',
method: 'GET',
})
.input(type<{
params: { appId: string }
query?: {
pageNumber?: number
resultsPerPage?: number
}
}>())
.output(type<ListEnvironmentDeploymentsReply>())
export const deploymentCandidatesContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/deployment-candidates',
method: 'GET',
})
.input(type<{ params: { appId: string } }>())
.output(type<ListDeploymentCandidatesReply>())
export const deploymentPlanContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/releases/{releaseId}/deployment-plan',
method: 'GET',
})
.input(type<{
params: {
appId: string
environmentId: string
releaseId: string
}
}>())
.output(type<GetDeploymentPlanReply>())
export const releaseHistoryContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/release-history',
method: 'GET',
})
.input(type<{
params: { appId: string }
query?: {
pageNumber?: number
resultsPerPage?: number
}
}>())
.output(type<ListReleaseHistoryReply>())
export const accessConfigContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/access-config',
method: 'GET',
})
.input(type<{ params: { appId: string } }>())
.output(type<GetAccessConfigReply>())
export const environmentAccessPolicyContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/deploy/access-policies/{channel}',
method: 'GET',
})
.input(type<{
params: {
appId: string
environmentId: string
channel: string
}
}>())
.output(type<GetEnvironmentAccessPolicyReply>())
export const updateEnvironmentAccessPolicyContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/deploy/access-policies/{channel}',
method: 'PUT',
})
.input(type<{
params: {
appId: string
environmentId: string
channel: string
}
body: {
channel: string
enabled: boolean
accessMode: string
subjects: AccessSubject[]
expectedVersion: number
}
}>())
.output(type<UpdateEnvironmentAccessPolicyReply>())
export const searchAccessSubjectsContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/access-subjects:search',
method: 'GET',
})
.input(type<{
params: { appId: string }
query?: {
keyword?: string
subjectTypes?: string[]
}
}>())
.output(type<SearchAccessSubjectsReply>())
export const patchAccessChannelContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/access-channels/{channel}',
method: 'PATCH',
})
.input(type<{
params: {
appId: string
channel: string
}
body: {
channel: string
enabled: boolean
expectedVersion: number
}
}>())
.output(type<PatchAccessChannelReply>())
export const createReleaseContract = base
.route({
path: '/enterprise/apps/{appId}/deploy/releases',
method: 'POST',
})
.input(type<{
params: { appId: string }
body: {
description?: string
name: string
}
}>())
.output(type<CreateReleaseReply>())
export const createDeploymentContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments',
method: 'POST',
})
.input(type<{
params: {
appId: string
environmentId: string
}
body: {
releaseId: string
bindings?: BindingsProto
replicas?: number
idempotencyKey?: string
}
}>())
.output(type<CreateDeploymentReply>())
export const cancelDeploymentContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments/{deploymentId}/cancel',
method: 'POST',
})
.input(type<{
params: {
appId: string
environmentId: string
deploymentId: string
}
body: {
idempotencyKey?: string
}
}>())
.output(type<CancelDeploymentReply>())
export const undeployEnvironmentContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments:undeploy',
method: 'POST',
})
.input(type<{
params: {
appId: string
environmentId: string
}
body: {
idempotencyKey?: string
}
}>())
.output(type<UndeployEnvironmentReply>())
export const rollbackEnvironmentContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments:rollback',
method: 'POST',
})
.input(type<{
params: {
appId: string
environmentId: string
}
body: {
targetReleaseId?: string
idempotencyKey?: string
}
}>())
.output(type<RollbackEnvironmentReply>())
export const environmentAPITokensContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/api-keys',
method: 'GET',
})
.input(type<{
params: {
appId: string
environmentId: string
}
}>())
.output(type<ListEnvironmentAPITokensReply>())
export const createEnvironmentAPITokenContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/api-keys',
method: 'POST',
})
.input(type<{
params: {
appId: string
environmentId: string
}
body: {
name: string
}
}>())
.output(type<CreateEnvironmentAPITokenReply>())
export const deleteEnvironmentAPITokenContract = base
.route({
path: '/enterprise/apps/{appId}/environments/{environmentId}/api-keys/{apiKeyId}',
method: 'DELETE',
})
.input(type<{
params: {
appId: string
environmentId: string
apiKeyId: string
}
}>())
.output(type<DeleteEnvironmentAPITokenReply>())

View File

@ -2,6 +2,26 @@ import type { InferContractRouterInputs } from '@orpc/contract'
import { accountAvatarContract } from './console/account'
import { appDeleteContract, workflowOnlineUsersContract } from './console/apps'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import {
accessConfigContract,
cancelDeploymentContract,
createDeploymentContract,
createEnvironmentAPITokenContract,
createReleaseContract,
deleteEnvironmentAPITokenContract,
deploymentCandidatesContract,
deploymentOverviewContract,
deploymentPlanContract,
environmentAccessPolicyContract,
environmentAPITokensContract,
environmentDeploymentsContract,
patchAccessChannelContract,
releaseHistoryContract,
rollbackEnvironmentContract,
searchAccessSubjectsContract,
undeployEnvironmentContract,
updateEnvironmentAccessPolicyContract,
} from './console/deployments'
import {
exploreAppDetailContract,
exploreAppsContract,
@ -91,6 +111,26 @@ export const consoleRouterContract = {
invoices: invoicesContract,
bindPartnerStack: bindPartnerStackContract,
},
deployments: {
overview: deploymentOverviewContract,
environmentDeployments: environmentDeploymentsContract,
candidates: deploymentCandidatesContract,
deploymentPlan: deploymentPlanContract,
releaseHistory: releaseHistoryContract,
accessConfig: accessConfigContract,
environmentAccessPolicy: environmentAccessPolicyContract,
updateEnvironmentAccessPolicy: updateEnvironmentAccessPolicyContract,
searchAccessSubjects: searchAccessSubjectsContract,
patchAccessChannel: patchAccessChannelContract,
createRelease: createReleaseContract,
createDeployment: createDeploymentContract,
cancelDeployment: cancelDeploymentContract,
undeployEnvironment: undeployEnvironmentContract,
rollbackEnvironment: rollbackEnvironmentContract,
environmentAPITokens: environmentAPITokensContract,
createEnvironmentAPIToken: createEnvironmentAPITokenContract,
deleteEnvironmentAPIToken: deleteEnvironmentAPITokenContract,
},
workflowDraft: {
environmentVariables: workflowDraftEnvironmentVariablesContract,
updateEnvironmentVariables: workflowDraftUpdateEnvironmentVariablesContract,

View File

@ -3,11 +3,15 @@
"access.api.description": "Access this instance over HTTP. Each API key is scoped to one environment.",
"access.api.developerTitle": "Developer API",
"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.envPrefix": "env: {{env}}",
"access.api.keyList": "API key list",
"access.api.newKey": "New key",
"access.api.newKeyForEnv": "Generate for {{env}}",
"access.api.newTokenDescription": "This token is shown only once. Copy it before leaving this page.",
"access.api.newTokenLabel": "Token",
"access.api.newTokenTitle": "API key created",
"access.api.noKeys": "No API keys yet. Create one to start calling the API.",
"access.api.title": "API",
"access.channels.description": "WebApp and CLI entry points use the access permissions above.",

View File

@ -3,11 +3,15 @@
"access.api.description": "通过 HTTP 调用该实例。每个 API 密钥仅在一个环境中生效。",
"access.api.developerTitle": "开发者 API",
"access.api.disabled": "该实例的 API 接入已关闭。",
"access.api.dismissToken": "关闭密钥",
"access.api.empty": "请先部署到环境后再签发 API 密钥。",
"access.api.envPrefix": "env{{env}}",
"access.api.keyList": "API Key 列表",
"access.api.newKey": "生成新 Key",
"access.api.newKeyForEnv": "为 {{env}} 生成",
"access.api.newTokenDescription": "该明文密钥仅本次显示,请在离开页面前复制保存。",
"access.api.newTokenLabel": "密钥",
"access.api.newTokenTitle": "API Key 已创建",
"access.api.noKeys": "尚无 API 密钥,创建一个即可调用 API。",
"access.api.title": "API",
"access.channels.description": "WebApp 与 CLI 入口遵循上方访问权限。",

View File

@ -36,10 +36,15 @@ const toUpstreamCookieName = (cookieName: string) => {
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
export const rewriteCookieHeaderForUpstream = (
cookieHeader?: string,
options: { useHostPrefix?: boolean } = {},
) => {
if (!cookieHeader)
return cookieHeader
const { useHostPrefix = true } = options
return cookieHeader
.split(/;\s*/)
.filter(Boolean)
@ -50,7 +55,7 @@ export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
const cookieName = cookie.slice(0, separatorIndex).trim()
const cookieValue = cookie.slice(separatorIndex + 1)
return `${toUpstreamCookieName(cookieName)}=${cookieValue}`
return `${useHostPrefix ? toUpstreamCookieName(cookieName) : cookieName}=${cookieValue}`
})
.join('; ')
}

View File

@ -109,6 +109,32 @@ describe('dev proxy server', () => {
])
})
// Scenario: a local HTTP Dify API expects the non-prefixed local cookie name.
it('should keep local cookie names for HTTP upstream targets', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
const app = createDevProxyApp({
consoleApiTarget: 'http://127.0.0.1:5001',
publicApiTarget: 'http://127.0.0.1:5001',
enterpriseApiTarget: 'http://127.0.0.1:8082',
fetchImpl,
})
// Act
await app.request('http://127.0.0.1:5010/console/api/account/profile', {
headers: {
Cookie: 'access_token=abc; refresh_token=def',
},
})
// Assert
const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers
if (!(requestHeaders instanceof Headers))
throw new Error('Expected proxy request headers to be Headers')
expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def')
})
// Scenario: Enterprise dashboard routes should use the Enterprise target before generic API routes.
it('should proxy enterprise api routes to the enterprise target', async () => {
// Arrange

View File

@ -113,7 +113,9 @@ const createProxyRequestHeaders = (request: Request, targetUrl: URL) => {
if (headers.has('origin'))
headers.set('origin', targetUrl.origin)
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined)
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, {
useHostPrefix: targetUrl.protocol === 'https:',
})
if (rewrittenCookieHeader)
headers.set('cookie', rewrittenCookieHeader)

214
web/service/deployments.ts Normal file
View File

@ -0,0 +1,214 @@
import type {
AccessSubject,
BindingsProto,
GetAccessConfigReply,
GetDeploymentOverviewReply,
ListDeploymentCandidatesReply,
ListEnvironmentDeploymentsReply,
ListReleaseHistoryReply,
} from '@/contract/console/deployments'
import { consoleClient } from './client'
const DEPLOYMENT_PAGE_SIZE = 100
export type DeploymentAppData = {
appId: string
overview: GetDeploymentOverviewReply
environmentDeployments: ListEnvironmentDeploymentsReply
candidates: ListDeploymentCandidatesReply
releaseHistory: ListReleaseHistoryReply
accessConfig: GetAccessConfigReply
}
export type CreateDeploymentParams = {
appId: string
environmentId: string
releaseId?: string
releaseNote?: string
bindings?: BindingsProto
}
const idempotencyKey = (prefix: string) => `${prefix}-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`
const defaultReleaseName = () => `deploy-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
export const fetchDeploymentAppData = async (appId: string): Promise<DeploymentAppData> => {
const input = { params: { appId } }
const [
overview,
environmentDeployments,
candidates,
releaseHistory,
accessConfig,
] = await Promise.all([
consoleClient.deployments.overview(input),
consoleClient.deployments.environmentDeployments({
...input,
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
}),
consoleClient.deployments.candidates(input),
consoleClient.deployments.releaseHistory({
...input,
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
}),
consoleClient.deployments.accessConfig(input),
])
return {
appId,
overview,
environmentDeployments,
candidates,
releaseHistory,
accessConfig,
}
}
export const createOrReuseRelease = async (
appId: string,
releaseId: string | undefined,
releaseNote: string | undefined,
) => {
if (releaseId)
return releaseId
const trimmedNote = releaseNote?.trim()
const response = await consoleClient.deployments.createRelease({
params: { appId },
body: {
description: trimmedNote || undefined,
name: trimmedNote || defaultReleaseName(),
},
})
const createdReleaseId = response.release?.id
if (!createdReleaseId)
throw new Error('Release creation did not return an id.')
return createdReleaseId
}
export const createDeployment = async ({
appId,
environmentId,
releaseId,
releaseNote,
bindings,
}: CreateDeploymentParams) => {
const targetReleaseId = await createOrReuseRelease(appId, releaseId, releaseNote)
return consoleClient.deployments.createDeployment({
params: {
appId,
environmentId,
},
body: {
releaseId: targetReleaseId,
bindings,
idempotencyKey: idempotencyKey('deploy'),
},
})
}
export const cancelDeployment = async (appId: string, environmentId: string, deploymentId: string) => {
return consoleClient.deployments.cancelDeployment({
params: {
appId,
environmentId,
deploymentId,
},
body: {
idempotencyKey: idempotencyKey('cancel'),
},
})
}
export const undeployEnvironment = async (appId: string, environmentId: string) => {
return consoleClient.deployments.undeployEnvironment({
params: {
appId,
environmentId,
},
body: {
idempotencyKey: idempotencyKey('undeploy'),
},
})
}
export const rollbackEnvironment = async (appId: string, environmentId: string, targetReleaseId: string) => {
return consoleClient.deployments.rollbackEnvironment({
params: {
appId,
environmentId,
},
body: {
targetReleaseId,
idempotencyKey: idempotencyKey('rollback'),
},
})
}
export const createApiKey = async (appId: string, environmentId: string, name: string) => {
return consoleClient.deployments.createEnvironmentAPIToken({
params: {
appId,
environmentId,
},
body: {
name,
},
})
}
export const deleteApiKey = async (appId: string, environmentId: string, apiKeyId: string) => {
return consoleClient.deployments.deleteEnvironmentAPIToken({
params: {
appId,
environmentId,
apiKeyId,
},
})
}
export const patchAccessChannel = async (appId: string, channel: string, enabled: boolean, expectedVersion = 0) => {
return consoleClient.deployments.patchAccessChannel({
params: {
appId,
channel,
},
body: {
channel,
enabled,
expectedVersion,
},
})
}
export const updateEnvironmentAccessPolicy = async (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[] = [],
expectedVersion = 0,
) => {
return consoleClient.deployments.updateEnvironmentAccessPolicy({
params: {
appId,
environmentId,
channel,
},
body: {
channel,
enabled,
accessMode,
subjects,
expectedVersion,
},
})
}