This commit is contained in:
Stephen Zhou 2026-04-29 13:25:41 +08:00
parent 6fa77397a4
commit 1aea4e00a4
No known key found for this signature in database
28 changed files with 2572 additions and 2295 deletions

View File

@ -1,434 +1,13 @@
'use client'
import type { FC } from 'react'
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 { useEffect, useMemo, useState } from 'react'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { consoleQuery } from '@/service/client'
import { deploymentAppDataQueryOptions } from '@/service/deployments'
import { deploymentAppDataQueryOptions } from '../data'
import { useDeploymentsStore } from '../store'
import { environmentHealth, environmentMode, environmentName, releaseCommit, releaseLabel } from '../utils'
import { HealthBadge, ModeBadge } from './status-badge'
type CredentialRequirement = {
slot: string
label: string
required: boolean
selectedCredentialId?: string
options: { id: string, label: string }[]
}
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 = {
label: string
hint?: string
children: React.ReactNode
}
const Field: FC<FieldProps> = ({ label, hint, children }) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
</div>
{children}
</div>
)
type SelectOption = { value: string, label: string }
type SelectProps = {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
}
const DeploymentSelect: FC<SelectProps> = ({ value, onChange, options, placeholder }) => {
const { t } = useTranslation('deployments')
const selectedOption = useMemo(
() => options.find(option => option.value === value),
[options, value],
)
return (
<Select
value={value || null}
onValueChange={(next) => {
if (!next)
return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
className={cn(
'h-8 border-[0.5px] border-components-input-border-active px-2 system-sm-medium',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
<SelectItemText>{opt.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
type LabeledSelectProps = SelectProps & { label: string }
const LabeledSelect: FC<LabeledSelectProps> = ({ label, ...rest }) => (
<div className="flex items-center gap-2">
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{label}</span>
<div className="min-w-0 flex-1">
<DeploymentSelect {...rest} />
</div>
</div>
)
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">{environmentName(env)}</span>
<ModeBadge mode={environmentMode(env)} />
<HealthBadge health={environmentHealth(env)} />
</div>
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
</div>
)
type DeployFormProps = {
appId: string
environments: EnvironmentOption[]
releases: ConsoleReleaseSummary[]
defaultReleaseId?: string
lockedEnvId?: string
presetReleaseId?: string
onCancel: () => void
onSubmit: (params: {
environmentId: string
releaseId?: string
releaseNote?: string
bindings?: BindingsProto
}) => void
}
const DeployForm: FC<DeployFormProps> = ({
appId,
environments,
releases,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
onCancel,
onSubmit,
}) => {
const { t } = useTranslation('deployments')
const presetRelease = useMemo(
() => presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined,
[releases, presetReleaseId],
)
const isPromote = Boolean(presetRelease)
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 [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>({})
const [envValues, setEnvValues] = useState<Record<string, string>>({})
const canDeploy = Boolean(
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
const handleDeploy = () => {
if (!canDeploy)
return
const bindings: BindingsProto = {
models: required.model.map(item => ({
slot: item.slot,
credentialId: credentialValue(modelCredentials, item),
})),
plugins: required.plugin.map(item => ({
slot: item.slot,
credentialId: credentialValue(pluginCredentials, item),
})),
envVars: required.envVars.map(item => ({
slot: item.key,
envVarId: envVarValue(envValues, item),
})),
}
onSubmit({
environmentId: selectedEnvironmentId,
releaseId: presetRelease?.id,
releaseNote: isPromote ? undefined : releaseNote,
bindings,
})
}
return (
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')}
</DialogDescription>
</div>
<Field label={isPromote ? t('deployDrawer.releaseLabel') : t('deployDrawer.noteLabel')}>
{isPromote && presetRelease
? (
<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">{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">{releaseCommit(presetRelease)}</span>
{presetRelease.description && (
<>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="truncate system-xs-regular text-text-secondary">{presetRelease.description}</span>
</>
)}
</div>
<span className="shrink-0 system-xs-regular text-text-quaternary">{presetRelease.createdAt}</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.existingReleaseHint')}
</span>
</div>
)
: (
<div className="flex flex-col gap-2">
<Input
value={releaseNote}
onChange={e => setReleaseNote(e.target.value)}
placeholder={t('deployDrawer.notePlaceholder')}
maxLength={80}
/>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.newReleaseHint')}
</span>
</div>
)}
</Field>
<Field
label={t('deployDrawer.targetEnv')}
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
>
{lockedEnv
? <EnvironmentRow env={lockedEnv} />
: (
<DeploymentSelect
value={selectedEnvironmentId}
onChange={setSelectedEnvId}
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')}
/>
)}
</Field>
{(required.model.length > 0 || required.plugin.length > 0) && (
<div className="flex flex-col gap-4">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
{required.model.length > 0 && (
<Field label={t('deployDrawer.modelCreds')}>
<div className="flex flex-col gap-2">
{required.model.map((item) => {
return (
<LabeledSelect
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: item.label })}
/>
)
})}
</div>
</Field>
)}
{required.plugin.length > 0 && (
<Field label={t('deployDrawer.pluginCreds')}>
<div className="flex flex-col gap-2">
{required.plugin.map((item) => {
return (
<LabeledSelect
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 })}
/>
)
})}
</div>
</Field>
)}
</div>
)}
{required.envVars.length > 0 && (
<Field label={t('deployDrawer.envVars')}>
<div className="flex flex-col gap-2">
{required.envVars.map(v => (
<div key={v.key} className="flex items-center gap-2">
<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')}
/>
</div>
</div>
))}
</div>
</Field>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>
{t('deployDrawer.cancel')}
</Button>
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
{isPromote ? t('deployDrawer.promote') : t('deployDrawer.deploy')}
</Button>
</div>
</div>
)
}
import { DeployForm } from './deploy-drawer/form'
const DeployDrawer: FC = () => {
const { t } = useTranslation('deployments')

View File

@ -0,0 +1,114 @@
import type { BindingsProto, DeploymentSlot } from '@/contract/console/deployments'
export type CredentialRequirement = {
slot: string
label: string
required: boolean
selectedCredentialId?: string
options: { id: string, label: string }[]
}
export type EnvVarRequirement = {
key: string
label: string
required: boolean
selectedEnvVarId?: string
type: 'string' | 'secret'
options: { id: string, label: string }[]
}
export 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
}
export 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
}
export function credentialValue(values: Record<string, string>, item: CredentialRequirement) {
return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || ''
}
export function envVarValue(values: Record<string, string>, item: EnvVarRequirement) {
return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || ''
}
export function deploymentBindings(
required: RequiredBindings,
modelCredentials: Record<string, string>,
pluginCredentials: Record<string, string>,
envValues: Record<string, string>,
): BindingsProto {
return {
models: required.model.map(item => ({
slot: item.slot,
credentialId: credentialValue(modelCredentials, item),
})),
plugins: required.plugin.map(item => ({
slot: item.slot,
credentialId: credentialValue(pluginCredentials, item),
})),
envVars: required.envVars.map(item => ({
slot: item.key,
envVarId: envVarValue(envValues, item),
})),
}
}

View File

@ -0,0 +1,246 @@
'use client'
import type { FC } from 'react'
import type { BindingsProto, ConsoleReleaseSummary, EnvironmentOption } from '@/contract/console/deployments'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { consoleQuery } from '@/service/client'
import { environmentMode, environmentName, releaseCommit, releaseLabel } from '../../utils'
import {
credentialValue,
deploymentBindings,
deriveRequiredBindings,
envVarValue,
} from './bindings'
import {
DeploymentSelect,
EnvironmentRow,
Field,
LabeledSelect,
} from './select'
export type DeployFormSubmit = {
environmentId: string
releaseId?: string
releaseNote?: string
bindings?: BindingsProto
}
type DeployFormProps = {
appId: string
environments: EnvironmentOption[]
releases: ConsoleReleaseSummary[]
defaultReleaseId?: string
lockedEnvId?: string
presetReleaseId?: string
onCancel: () => void
onSubmit: (params: DeployFormSubmit) => void
}
export const DeployForm: FC<DeployFormProps> = ({
appId,
environments,
releases,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
onCancel,
onSubmit,
}) => {
const { t } = useTranslation('deployments')
const presetRelease = useMemo(
() => presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined,
[releases, presetReleaseId],
)
const isPromote = Boolean(presetRelease)
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 [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>({})
const [envValues, setEnvValues] = useState<Record<string, string>>({})
const canDeploy = Boolean(
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
const handleDeploy = () => {
if (!canDeploy)
return
onSubmit({
environmentId: selectedEnvironmentId,
releaseId: presetRelease?.id,
releaseNote: isPromote ? undefined : releaseNote,
bindings: deploymentBindings(required, modelCredentials, pluginCredentials, envValues),
})
}
return (
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')}
</DialogDescription>
</div>
<Field label={isPromote ? t('deployDrawer.releaseLabel') : t('deployDrawer.noteLabel')}>
{isPromote && presetRelease
? (
<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">{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">{releaseCommit(presetRelease)}</span>
{presetRelease.description && (
<>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="truncate system-xs-regular text-text-secondary">{presetRelease.description}</span>
</>
)}
</div>
<span className="shrink-0 system-xs-regular text-text-quaternary">{presetRelease.createdAt}</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.existingReleaseHint')}
</span>
</div>
)
: (
<div className="flex flex-col gap-2">
<Input
value={releaseNote}
onChange={e => setReleaseNote(e.target.value)}
placeholder={t('deployDrawer.notePlaceholder')}
maxLength={80}
/>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.newReleaseHint')}
</span>
</div>
)}
</Field>
<Field
label={t('deployDrawer.targetEnv')}
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
>
{lockedEnv
? <EnvironmentRow env={lockedEnv} />
: (
<DeploymentSelect
value={selectedEnvironmentId}
onChange={setSelectedEnvId}
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')}
/>
)}
</Field>
{(required.model.length > 0 || required.plugin.length > 0) && (
<div className="flex flex-col gap-4">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
{required.model.length > 0 && (
<Field label={t('deployDrawer.modelCreds')}>
<div className="flex flex-col gap-2">
{required.model.map(item => (
<LabeledSelect
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: item.label })}
/>
))}
</div>
</Field>
)}
{required.plugin.length > 0 && (
<Field label={t('deployDrawer.pluginCreds')}>
<div className="flex flex-col gap-2">
{required.plugin.map(item => (
<LabeledSelect
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 })}
/>
))}
</div>
</Field>
)}
</div>
)}
{required.envVars.length > 0 && (
<Field label={t('deployDrawer.envVars')}>
<div className="flex flex-col gap-2">
{required.envVars.map(v => (
<div key={v.key} className="flex items-center gap-2">
<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')}
/>
</div>
</div>
))}
</div>
</Field>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>
{t('deployDrawer.cancel')}
</Button>
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
{isPromote ? t('deployDrawer.promote') : t('deployDrawer.deploy')}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,96 @@
'use client'
import type { FC } from 'react'
import type { EnvironmentOption } from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { environmentHealth, environmentMode, environmentName } from '../../utils'
import { HealthBadge, ModeBadge } from '../status-badge'
type FieldProps = {
label: string
hint?: string
children: React.ReactNode
}
export const Field: FC<FieldProps> = ({ label, hint, children }) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
</div>
{children}
</div>
)
type SelectOption = { value: string, label: string }
type SelectProps = {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
}
export const DeploymentSelect: FC<SelectProps> = ({ value, onChange, options, placeholder }) => {
const { t } = useTranslation('deployments')
const selectedOption = useMemo(
() => options.find(option => option.value === value),
[options, value],
)
return (
<Select
value={value || null}
onValueChange={(next) => {
if (!next)
return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
className={cn(
'h-8 border-[0.5px] border-components-input-border-active px-2 system-sm-medium',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
<SelectItemText>{opt.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
type LabeledSelectProps = SelectProps & { label: string }
export const LabeledSelect: FC<LabeledSelectProps> = ({ label, ...rest }) => (
<div className="flex items-center gap-2">
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{label}</span>
<div className="min-w-0 flex-1">
<DeploymentSelect {...rest} />
</div>
</div>
)
type EnvironmentRowProps = { env: EnvironmentOption }
export 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">{environmentName(env)}</span>
<ModeBadge mode={environmentMode(env)} />
<HealthBadge health={environmentHealth(env)} />
</div>
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
</div>
)

View File

@ -9,7 +9,7 @@ import type {
} from '@/contract/console/deployments'
import { queryOptions } from '@tanstack/react-query'
import { getQueryClient } from '@/context/get-query-client'
import { consoleClient } from './client'
import { consoleClient } from '@/service/client'
const DEPLOYMENT_PAGE_SIZE = 100
const DEPLOYMENT_APP_DATA_STALE_TIME = 30 * 1000

View File

@ -1,666 +1,23 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { AccessPermissionKind } from '../types'
import type { FC } from 'react'
import type {
AccessPolicyDetail,
AccessSubject,
AccessSubjectDisplay,
APIToken,
ConsoleEnvironmentSummary,
DeveloperAPIKeySummary,
EffectivePolicySummary,
} from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken, useQueries, useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQueries } from '@tanstack/react-query'
import { useMemo } from 'react'
import { consoleQuery } from '@/service/client'
import { useDeploymentsStore } from '../store'
import {
accessModeToPermissionKey,
deployedRows,
environmentName,
permissionKeyToAccessMode,
webappUrl,
} from '../utils'
type SectionProps = {
title: string
description?: string
action?: ReactNode
children: ReactNode
}
const Section: FC<SectionProps> = ({ title, description, action, children }) => (
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">{title}</div>
{description && (
<p className="mt-1 max-w-xl system-xs-regular text-text-tertiary">{description}</p>
)}
</div>
{action}
</div>
{children}
</div>
)
type CopyPillProps = {
label: string
value: string
prefix?: ReactNode
className?: string
}
const CopyPill: FC<CopyPillProps> = ({ label, value, prefix, className }) => {
const { t } = useTranslation('deployments')
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value)
setCopied(true)
toast.success(t('access.copyToast'))
window.setTimeout(() => setCopied(false), 1500)
}
catch {
toast.error(t('access.copyFailed'))
}
}
return (
<div
className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5',
className,
)}
>
<div className="mr-0.5 flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 text-[11px] font-medium text-text-tertiary">
{label}
</div>
{prefix}
<div className="min-w-0 flex-1 truncate px-1 font-mono text-[13px] font-medium text-text-secondary">
{value}
</div>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
<button
type="button"
onClick={handleCopy}
aria-label={t('access.copy')}
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(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
</button>
</div>
)
}
type ApiKeyRowProps = {
apiKey: DeveloperAPIKeySummary
onRevoke: () => void
}
const ApiKeyRow: FC<ApiKeyRowProps> = ({ apiKey, onRevoke }) => {
const { t } = useTranslation('deployments')
const [copied, setCopied] = useState(false)
const displayValue = apiKey.maskedPrefix || apiKey.id || '—'
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(displayValue)
setCopied(true)
toast.success(t('access.copyToast'))
window.setTimeout(() => setCopied(false), 1500)
}
catch {
toast.error(t('access.copyFailed'))
}
}
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">{apiKey.name || apiKey.id}</span>
<span className="system-xs-regular text-text-tertiary">
{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={handleCopy}
aria-label={t('access.copy')}
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(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
</button>
<button
type="button"
onClick={onRevoke}
aria-label={t('access.revoke')}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
>
<span className="i-ri-delete-bin-line h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
const permissionIcon: Record<AccessPermissionKind, string> = {
organization: 'i-ri-team-line',
specific: 'i-ri-lock-line',
anyone: 'i-ri-global-line',
}
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
type PermissionPickerProps = {
value: AccessPermissionKind
disabled?: boolean
onChange: (kind: AccessPermissionKind) => void
}
const PermissionPicker: FC<PermissionPickerProps> = ({ value, disabled, onChange }) => {
const { t } = useTranslation('deployments')
const icon = permissionIcon[value]
const label = t(`access.permission.${value}`)
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 min-w-[220px] items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
disabled && 'opacity-50',
)}
>
<span className={cn(icon, 'h-4 w-4 shrink-0 text-text-tertiary')} />
<span className="flex-1 truncate text-left">{label}</span>
<span className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[340px] p-1">
{permissionOrder.map((kind) => {
const itemIcon = permissionIcon[kind]
const isSelected = kind === value
return (
<DropdownMenuItem
key={kind}
onSelect={() => onChange(kind)}
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
>
<span className={cn(itemIcon, 'mt-0.5 h-4 w-4 shrink-0 text-text-tertiary')} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate system-sm-medium text-text-primary">
{t(`access.permission.${kind}`)}
</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t(`access.permission.${kind}Desc`)}
</span>
</div>
{isSelected && (
<span className="mt-0.5 i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
type SelectableAccessSubject = AccessSubjectDisplay & {
id: string
subjectType: string
}
function normalizeSubject(subject: AccessSubjectDisplay): SelectableAccessSubject | undefined {
if (!subject.id || !subject.subjectType)
return undefined
return {
...subject,
id: subject.id,
subjectType: subject.subjectType,
}
}
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
return `${subject.subjectType}:${subject.id}`
}
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
return subjects.map(subject => ({
subjectId: subject.id,
subjectType: subject.subjectType,
}))
}
function selectedSubjectsFromPolicy(policy?: AccessPolicyDetail) {
const selectedOption = policy?.options?.find(option => option.selected)
?? policy?.options?.find(option => option.mode === policy?.accessMode)
return [
...(selectedOption?.groups ?? []),
...(selectedOption?.members ?? []),
].map(normalizeSubject).filter((subject): subject is SelectableAccessSubject => Boolean(subject))
}
type SubjectPillProps = {
subject: SelectableAccessSubject
disabled?: boolean
onRemove: () => void
}
const SubjectPill: FC<SubjectPillProps> = ({ subject, disabled, onRemove }) => {
const { t } = useTranslation('deployments')
const isGroup = subject.subjectType === 'group'
return (
<div className="inline-flex max-w-full items-center gap-1 rounded-full border border-divider-subtle bg-components-badge-white-to-dark px-2 py-1">
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
<span className="truncate system-xs-medium text-text-secondary">{subject.name || subject.id}</span>
{isGroup && subject.memberCount != null && (
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
)}
<button
type="button"
disabled={disabled}
onClick={onRemove}
aria-label={t('operation.remove', { ns: 'common' })}
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-text-quaternary hover:text-text-secondary',
disabled && 'cursor-not-allowed opacity-40',
)}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
</button>
</div>
)
}
type SubjectPickerProps = {
appId: string
disabled?: boolean
selectedSubjects: SelectableAccessSubject[]
onChange: (subjects: SelectableAccessSubject[]) => void
}
const SubjectPicker: FC<SubjectPickerProps> = ({
appId,
disabled,
selectedSubjects,
onChange,
}) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
const selectedKeys = useMemo(
() => new Set(selectedSubjects.map(subjectKey)),
[selectedSubjects],
)
const subjectsQuery = useQuery(consoleQuery.deployments.searchAccessSubjects.queryOptions({
input: open
? {
params: { appId },
query: {
keyword: debouncedKeyword.trim() || undefined,
subjectTypes: ['account', 'group'],
},
}
: skipToken,
staleTime: 30 * 1000,
}))
const subjects = useMemo(
() => subjectsQuery.data?.data
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? [],
[subjectsQuery.data?.data],
)
const toggleSubject = (subject: SelectableAccessSubject) => {
const key = subjectKey(subject)
if (selectedKeys.has(key)) {
if (selectedSubjects.length <= 1)
return
onChange(selectedSubjects.filter(item => subjectKey(item) !== key))
return
}
onChange([...selectedSubjects, subject])
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
disabled={disabled}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span className="i-ri-add-line h-3.5 w-3.5" />
{t('access.members.pickPlaceholder')}
</button>
)}
/>
{open && (
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[360px] p-0">
<div className="flex max-h-[420px] flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="border-b border-divider-subtle p-2">
<div className="flex h-8 items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
<span className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder={t('access.members.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent system-sm-regular text-text-primary outline-none placeholder:text-text-quaternary"
/>
</div>
</div>
<div className="min-h-10 overflow-y-auto p-1">
{subjectsQuery.isLoading
? (
<div className="flex h-16 items-center justify-center">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
</div>
)
: subjects.length === 0
? (
<div className="px-3 py-5 text-center system-xs-regular text-text-tertiary">
{t('access.members.empty')}
</div>
)
: subjects.map((subject) => {
const isSelected = selectedKeys.has(subjectKey(subject))
const isGroup = subject.subjectType === 'group'
return (
<button
key={subjectKey(subject)}
type="button"
onClick={() => toggleSubject(subject)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left hover:bg-state-base-hover"
>
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-4 w-4 shrink-0 text-text-tertiary')} />
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
{subject.name || subject.id}
</span>
{isGroup && subject.memberCount != null && (
<span className="system-xs-regular text-text-tertiary">
{t('access.members.memberCount', { count: subject.memberCount })}
</span>
)}
{isSelected && (
<span className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</button>
)
})}
</div>
</div>
</PopoverContent>
)}
</Popover>
)
}
type EnvironmentPermissionRowProps = {
appId: string
environment: ConsoleEnvironmentSummary
summaryPolicy?: EffectivePolicySummary
onSetPolicy: (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
expectedVersion: number,
) => Promise<void>
}
const EnvironmentPermissionRow: FC<EnvironmentPermissionRowProps> = ({
appId,
environment,
summaryPolicy,
onSetPolicy,
}) => {
const { t } = useTranslation('deployments')
const environmentId = environment.id
const channel = summaryPolicy?.channel ?? 'webapp'
const policyQuery = useQuery(consoleQuery.deployments.environmentAccessPolicy.queryOptions({
input: environmentId
? {
params: {
appId,
environmentId,
channel,
},
}
: skipToken,
staleTime: 30 * 1000,
}))
const detailPolicy = policyQuery.data?.policy
const policyKind = accessModeToPermissionKey(detailPolicy?.accessMode ?? summaryPolicy?.accessMode)
const policyFingerprint = [
detailPolicy?.id ?? 'new',
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
detailPolicy?.accessMode ?? summaryPolicy?.accessMode ?? '',
].join(':')
const policySelectedSubjects = useMemo(
() => policyKind === 'specific' ? selectedSubjectsFromPolicy(detailPolicy) : [],
[detailPolicy, policyKind],
)
const [draft, setDraft] = useState<{
fingerprint?: string
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const [isSaving, setIsSaving] = useState(false)
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
const persistPolicy = async (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
if (!environmentId)
return
if (nextKind === 'specific' && nextSubjects.length === 0)
return
setIsSaving(true)
try {
await onSetPolicy(
appId,
environmentId,
detailPolicy?.channel ?? channel,
detailPolicy?.enabled ?? summaryPolicy?.enabled ?? true,
permissionKeyToAccessMode(nextKind),
nextKind === 'specific' ? policySubjects(nextSubjects) : [],
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
)
await policyQuery.refetch()
setDraft({})
}
catch {
toast.error(t('access.permission.updateFailed'))
}
finally {
setIsSaving(false)
}
}
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
setDraft({
fingerprint: policyFingerprint,
kind: nextKind,
subjects: nextKind === 'specific' ? subjects : [],
})
if (nextKind === 'specific') {
void persistPolicy(nextKind, subjects)
return
}
void persistPolicy(nextKind, [])
}
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
if (nextSubjects.length === 0)
return
setDraft({
fingerprint: policyFingerprint,
kind: 'specific',
subjects: nextSubjects,
})
void persistPolicy('specific', nextSubjects)
}
return (
<div className="flex flex-col gap-1.5">
<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">
{environmentName(environment)}
</span>
<PermissionPicker
value={permissionKind}
disabled={isSaving || policyQuery.isLoading}
onChange={handlePermissionChange}
/>
</div>
{permissionKind === 'specific' && (
<div className="flex flex-col gap-2 pl-0 sm:pl-[152px]">
<div className="flex flex-wrap items-center gap-2">
<SubjectPicker
appId={appId}
selectedSubjects={subjects}
disabled={isSaving || policyQuery.isLoading}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
<span className="system-xs-regular text-text-tertiary">
{t('access.members.emptySelection')}
</span>
)}
</div>
{subjects.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{subjects.map(subject => (
<SubjectPill
key={subjectKey(subject)}
subject={subject}
disabled={isSaving || subjects.length <= 1}
onRemove={() => handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))}
/>
))}
</div>
)}
</div>
)}
</div>
)
}
type EndpointRowProps = {
envName: string
label: string
value: string
openLabel?: string
}
const EndpointRow: FC<EndpointRowProps> = ({ envName, label, value, openLabel }) => (
<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">
{envName}
</span>
<CopyPill label={label} value={value} className="min-w-[260px] flex-1" />
{openLabel && (
<a
href={value}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-external-link-line h-3.5 w-3.5" />
{openLabel}
</a>
)}
</div>
)
type ApiKeyGenerateMenuProps = {
environments: ConsoleEnvironmentSummary[]
onGenerate: (environmentId: string) => void
}
const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGenerate }) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const selectableEnvironments = environments.filter(env => env.id)
const disabled = selectableEnvironments.length === 0
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 system-sm-medium',
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text',
'hover:bg-components-button-secondary-bg-hover',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span className="i-ri-add-line h-3.5 w-3.5" />
{t('access.api.newKey')}
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
{open && !disabled && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
{selectableEnvironments.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
onClick={() => {
setOpen(false)
onGenerate(env.id!)
}}
>
<span className="system-sm-regular text-text-secondary">
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
function getUrlOrigin(url?: string) {
if (!url)
return undefined
try {
return new URL(url).origin
}
catch {
return url
}
}
import { AccessChannelsSection } from './access-tab/channels-section'
import { DeveloperApiSection } from './access-tab/developer-api-section'
import { AccessPermissionsSection } from './access-tab/permissions-section'
import { getUrlOrigin } from './access-tab/url'
function uniqueEnvironments(environments: (ConsoleEnvironmentSummary | undefined)[]) {
return environments.filter((environment, index): environment is ConsoleEnvironmentSummary => {
@ -675,7 +32,6 @@ type AccessTabProps = {
}
const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
const { t } = useTranslation('deployments')
const appData = useDeploymentsStore(state => state.appData[appId])
const createdApiToken = useDeploymentsStore(state => state.createdApiToken)
const clearCreatedApiToken = useDeploymentsStore(state => state.clearCreatedApiToken)
@ -752,224 +108,29 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
return (
<div className="flex flex-col gap-5 p-6">
<Section
title={t('access.permissions.title')}
description={t('access.permissions.description')}
>
{deployedEnvs.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">
{t('access.runAccess.noEnvs')}
</div>
)
: (
<div className="flex flex-col gap-3">
{deployedEnvs.map((env) => {
const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy
return (
<EnvironmentPermissionRow
key={env.id}
appId={appId}
environment={env}
summaryPolicy={policy}
onSetPolicy={setEnvironmentAccessPolicy}
/>
)
})}
</div>
)}
</Section>
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
action={(
<Switch
checked={runEnabled}
onCheckedChange={v => toggleAccessChannel(appId, 'webapp', v, webappChannelVersion)}
/>
)}
>
{runEnabled
? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.runAccess.webapp')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.runAccess.webappDesc')}
</div>
{webappRows.length > 0
? (
<div className="flex flex-col gap-2">
{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>
)
: (
<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">
{t('access.runAccess.webappEmpty')}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 border-t border-divider-subtle pt-3">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.cli.title')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.description')}
</div>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-[260px] flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line h-3.5 w-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line h-3.5 w-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
</div>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.channels.disabled')}
</div>
)}
</Section>
<Section
title={t('access.api.developerTitle')}
description={t('access.api.description')}
action={(
<Switch
checked={apiEnabled}
onCheckedChange={v => toggleAccessChannel(appId, 'api', v, 0)}
/>
)}
>
{apiEnabled
? (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.backendTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.keyList')}
</span>
</div>
<ApiKeyGenerateMenu
environments={deployedEnvs}
onGenerate={handleGenerateApiKey}
/>
</div>
{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
? t('access.api.empty')
: t('access.api.noKeys')}
</div>
)
: (
<div className="flex flex-col divide-y divide-divider-subtle">
{apiKeys.map((apiKey) => {
if (!apiKey.id || !apiKey.environmentId)
return null
return (
<ApiKeyRow
key={apiKey.id}
apiKey={apiKey}
onRevoke={() => handleRevokeApiKey(apiKey.environmentId!, apiKey.id!)}
/>
)
})}
</div>
)}
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.api.disabled')}
</div>
)}
</Section>
<AccessPermissionsSection
appId={appId}
environments={deployedEnvs}
policies={policies}
onSetPolicy={setEnvironmentAccessPolicy}
/>
<AccessChannelsSection
runEnabled={runEnabled}
webappRows={webappRows}
cliDomain={cliDomain}
cliDocsUrl={cliDocsUrl}
onToggle={enabled => toggleAccessChannel(appId, 'webapp', enabled, webappChannelVersion)}
/>
<DeveloperApiSection
apiEnabled={apiEnabled}
environments={deployedEnvs}
apiKeys={apiKeys}
createdToken={visibleCreatedApiToken?.token}
onToggle={enabled => toggleAccessChannel(appId, 'api', enabled, 0)}
onGenerate={handleGenerateApiKey}
onRevoke={handleRevokeApiKey}
onClearCreatedToken={clearCreatedApiToken}
/>
</div>
)
}

View File

@ -0,0 +1,118 @@
'use client'
import type { FC } from 'react'
import type { ConsoleEnvironmentSummary, DeveloperAPIKeySummary } from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { environmentName } from '../../utils'
type ApiKeyRowProps = {
apiKey: DeveloperAPIKeySummary
onRevoke: () => void
}
export const ApiKeyRow: FC<ApiKeyRowProps> = ({ apiKey, onRevoke }) => {
const { t } = useTranslation('deployments')
const [copied, setCopied] = useState(false)
const displayValue = apiKey.maskedPrefix || apiKey.id || '—'
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(displayValue)
setCopied(true)
toast.success(t('access.copyToast'))
window.setTimeout(() => setCopied(false), 1500)
}
catch {
toast.error(t('access.copyFailed'))
}
}
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">{apiKey.name || apiKey.id}</span>
<span className="system-xs-regular text-text-tertiary">
{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={handleCopy}
aria-label={t('access.copy')}
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(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
</button>
<button
type="button"
onClick={onRevoke}
aria-label={t('access.revoke')}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
>
<span className="i-ri-delete-bin-line h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
type ApiKeyGenerateMenuProps = {
environments: ConsoleEnvironmentSummary[]
onGenerate: (environmentId: string) => void
}
export const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGenerate }) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const selectableEnvironments = environments.filter(env => env.id)
const disabled = selectableEnvironments.length === 0
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 system-sm-medium',
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text',
'hover:bg-components-button-secondary-bg-hover',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span className="i-ri-add-line h-3.5 w-3.5" />
{t('access.api.newKey')}
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
{open && !disabled && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
{selectableEnvironments.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
onClick={() => {
setOpen(false)
onGenerate(env.id!)
}}
>
<span className="system-sm-regular text-text-secondary">
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -0,0 +1,134 @@
'use client'
import type { FC } from 'react'
import type { WebAppAccessRow } from '@/contract/console/deployments'
import { Switch } from '@langgenius/dify-ui/switch'
import { useTranslation } from 'react-i18next'
import { environmentName, webappUrl } from '../../utils'
import { CopyPill, EndpointRow, Section } from './common'
type AccessChannelsSectionProps = {
runEnabled: boolean
webappRows: WebAppAccessRow[]
cliDomain?: string
cliDocsUrl?: string
onToggle: (enabled: boolean) => void
}
export const AccessChannelsSection: FC<AccessChannelsSectionProps> = ({
runEnabled,
webappRows,
cliDomain,
cliDocsUrl,
onToggle,
}) => {
const { t } = useTranslation('deployments')
return (
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
action={(
<Switch
checked={runEnabled}
onCheckedChange={onToggle}
/>
)}
>
{runEnabled
? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.runAccess.webapp')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.runAccess.webappDesc')}
</div>
{webappRows.length > 0
? (
<div className="flex flex-col gap-2">
{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>
)
: (
<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">
{t('access.runAccess.webappEmpty')}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 border-t border-divider-subtle pt-3">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.cli.title')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.description')}
</div>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-[260px] flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line h-3.5 w-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line h-3.5 w-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
</div>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.channels.disabled')}
</div>
)}
</Section>
)
}

View File

@ -0,0 +1,106 @@
'use client'
import type { FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
type SectionProps = {
title: string
description?: string
action?: ReactNode
children: ReactNode
}
export const Section: FC<SectionProps> = ({ title, description, action, children }) => (
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">{title}</div>
{description && (
<p className="mt-1 max-w-xl system-xs-regular text-text-tertiary">{description}</p>
)}
</div>
{action}
</div>
{children}
</div>
)
type CopyPillProps = {
label: string
value: string
prefix?: ReactNode
className?: string
}
export const CopyPill: FC<CopyPillProps> = ({ label, value, prefix, className }) => {
const { t } = useTranslation('deployments')
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value)
setCopied(true)
toast.success(t('access.copyToast'))
window.setTimeout(() => setCopied(false), 1500)
}
catch {
toast.error(t('access.copyFailed'))
}
}
return (
<div
className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5',
className,
)}
>
<div className="mr-0.5 flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 text-[11px] font-medium text-text-tertiary">
{label}
</div>
{prefix}
<div className="min-w-0 flex-1 truncate px-1 font-mono text-[13px] font-medium text-text-secondary">
{value}
</div>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
<button
type="button"
onClick={handleCopy}
aria-label={t('access.copy')}
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(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
</button>
</div>
)
}
type EndpointRowProps = {
envName: string
label: string
value: string
openLabel?: string
}
export const EndpointRow: FC<EndpointRowProps> = ({ envName, label, value, openLabel }) => (
<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">
{envName}
</span>
<CopyPill label={label} value={value} className="min-w-[260px] flex-1" />
{openLabel && (
<a
href={value}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-external-link-line h-3.5 w-3.5" />
{openLabel}
</a>
)}
</div>
)

View File

@ -0,0 +1,119 @@
'use client'
import type { FC } from 'react'
import type { ConsoleEnvironmentSummary, DeveloperAPIKeySummary } from '@/contract/console/deployments'
import { Switch } from '@langgenius/dify-ui/switch'
import { useTranslation } from 'react-i18next'
import { ApiKeyGenerateMenu, ApiKeyRow } from './api-keys'
import { CopyPill, Section } from './common'
type DeveloperApiSectionProps = {
apiEnabled: boolean
environments: ConsoleEnvironmentSummary[]
apiKeys: DeveloperAPIKeySummary[]
createdToken?: string
onToggle: (enabled: boolean) => void
onGenerate: (environmentId: string) => void
onRevoke: (environmentId: string, apiKeyId: string) => void
onClearCreatedToken: () => void
}
export const DeveloperApiSection: FC<DeveloperApiSectionProps> = ({
apiEnabled,
environments,
apiKeys,
createdToken,
onToggle,
onGenerate,
onRevoke,
onClearCreatedToken,
}) => {
const { t } = useTranslation('deployments')
return (
<Section
title={t('access.api.developerTitle')}
description={t('access.api.description')}
action={(
<Switch
checked={apiEnabled}
onCheckedChange={onToggle}
/>
)}
>
{apiEnabled
? (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.backendTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.keyList')}
</span>
</div>
<ApiKeyGenerateMenu
environments={environments}
onGenerate={onGenerate}
/>
</div>
{createdToken && (
<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={onClearCreatedToken}
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={createdToken}
/>
</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">
{environments.length === 0
? t('access.api.empty')
: t('access.api.noKeys')}
</div>
)
: (
<div className="flex flex-col divide-y divide-divider-subtle">
{apiKeys.map((apiKey) => {
if (!apiKey.id || !apiKey.environmentId)
return null
return (
<ApiKeyRow
key={apiKey.id}
apiKey={apiKey}
onRevoke={() => onRevoke(apiKey.environmentId!, apiKey.id!)}
/>
)
})}
</div>
)}
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.api.disabled')}
</div>
)}
</Section>
)
}

View File

@ -0,0 +1,61 @@
'use client'
import type { FC } from 'react'
import type { AccessSubject, ConsoleEnvironmentSummary, EnvironmentPolicySummary } from '@/contract/console/deployments'
import { useTranslation } from 'react-i18next'
import { Section } from './common'
import { EnvironmentPermissionRow } from './permissions'
type AccessPermissionsSectionProps = {
appId: string
environments: ConsoleEnvironmentSummary[]
policies: EnvironmentPolicySummary[]
onSetPolicy: (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
expectedVersion: number,
) => Promise<void>
}
export const AccessPermissionsSection: FC<AccessPermissionsSectionProps> = ({
appId,
environments,
policies,
onSetPolicy,
}) => {
const { t } = useTranslation('deployments')
return (
<Section
title={t('access.permissions.title')}
description={t('access.permissions.description')}
>
{environments.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">
{t('access.runAccess.noEnvs')}
</div>
)
: (
<div className="flex flex-col gap-3">
{environments.map((env) => {
const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy
return (
<EnvironmentPermissionRow
key={env.id}
appId={appId}
environment={env}
summaryPolicy={policy}
onSetPolicy={onSetPolicy}
/>
)
})}
</div>
)}
</Section>
)
}

View File

@ -0,0 +1,445 @@
'use client'
import type { FC } from 'react'
import type { AccessPermissionKind } from '../../types'
import type {
AccessPolicyDetail,
AccessSubject,
AccessSubjectDisplay,
ConsoleEnvironmentSummary,
EffectivePolicySummary,
} from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
accessModeToPermissionKey,
environmentName,
permissionKeyToAccessMode,
} from '../../utils'
const permissionIcon: Record<AccessPermissionKind, string> = {
organization: 'i-ri-team-line',
specific: 'i-ri-lock-line',
anyone: 'i-ri-global-line',
}
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
type PermissionPickerProps = {
value: AccessPermissionKind
disabled?: boolean
onChange: (kind: AccessPermissionKind) => void
}
const PermissionPicker: FC<PermissionPickerProps> = ({ value, disabled, onChange }) => {
const { t } = useTranslation('deployments')
const icon = permissionIcon[value]
const label = t(`access.permission.${value}`)
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 min-w-[220px] items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
disabled && 'opacity-50',
)}
>
<span className={cn(icon, 'h-4 w-4 shrink-0 text-text-tertiary')} />
<span className="flex-1 truncate text-left">{label}</span>
<span className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[340px] p-1">
{permissionOrder.map((kind) => {
const itemIcon = permissionIcon[kind]
const isSelected = kind === value
return (
<DropdownMenuItem
key={kind}
onSelect={() => onChange(kind)}
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
>
<span className={cn(itemIcon, 'mt-0.5 h-4 w-4 shrink-0 text-text-tertiary')} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate system-sm-medium text-text-primary">
{t(`access.permission.${kind}`)}
</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t(`access.permission.${kind}Desc`)}
</span>
</div>
{isSelected && (
<span className="mt-0.5 i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
type SelectableAccessSubject = AccessSubjectDisplay & {
id: string
subjectType: string
}
function normalizeSubject(subject: AccessSubjectDisplay): SelectableAccessSubject | undefined {
if (!subject.id || !subject.subjectType)
return undefined
return {
...subject,
id: subject.id,
subjectType: subject.subjectType,
}
}
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
return `${subject.subjectType}:${subject.id}`
}
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
return subjects.map(subject => ({
subjectId: subject.id,
subjectType: subject.subjectType,
}))
}
function selectedSubjectsFromPolicy(policy?: AccessPolicyDetail) {
const selectedOption = policy?.options?.find(option => option.selected)
?? policy?.options?.find(option => option.mode === policy?.accessMode)
return [
...(selectedOption?.groups ?? []),
...(selectedOption?.members ?? []),
].map(normalizeSubject).filter((subject): subject is SelectableAccessSubject => Boolean(subject))
}
type SubjectPillProps = {
subject: SelectableAccessSubject
disabled?: boolean
onRemove: () => void
}
const SubjectPill: FC<SubjectPillProps> = ({ subject, disabled, onRemove }) => {
const { t } = useTranslation('deployments')
const isGroup = subject.subjectType === 'group'
return (
<div className="inline-flex max-w-full items-center gap-1 rounded-full border border-divider-subtle bg-components-badge-white-to-dark px-2 py-1">
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
<span className="truncate system-xs-medium text-text-secondary">{subject.name || subject.id}</span>
{isGroup && subject.memberCount != null && (
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
)}
<button
type="button"
disabled={disabled}
onClick={onRemove}
aria-label={t('operation.remove', { ns: 'common' })}
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-text-quaternary hover:text-text-secondary',
disabled && 'cursor-not-allowed opacity-40',
)}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
</button>
</div>
)
}
type SubjectPickerProps = {
appId: string
disabled?: boolean
selectedSubjects: SelectableAccessSubject[]
onChange: (subjects: SelectableAccessSubject[]) => void
}
const SubjectPicker: FC<SubjectPickerProps> = ({
appId,
disabled,
selectedSubjects,
onChange,
}) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
const selectedKeys = useMemo(
() => new Set(selectedSubjects.map(subjectKey)),
[selectedSubjects],
)
const subjectsQuery = useQuery(consoleQuery.deployments.searchAccessSubjects.queryOptions({
input: open
? {
params: { appId },
query: {
keyword: debouncedKeyword.trim() || undefined,
subjectTypes: ['account', 'group'],
},
}
: skipToken,
staleTime: 30 * 1000,
}))
const subjects = useMemo(
() => subjectsQuery.data?.data
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? [],
[subjectsQuery.data?.data],
)
const toggleSubject = (subject: SelectableAccessSubject) => {
const key = subjectKey(subject)
if (selectedKeys.has(key)) {
if (selectedSubjects.length <= 1)
return
onChange(selectedSubjects.filter(item => subjectKey(item) !== key))
return
}
onChange([...selectedSubjects, subject])
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
disabled={disabled}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span className="i-ri-add-line h-3.5 w-3.5" />
{t('access.members.pickPlaceholder')}
</button>
)}
/>
{open && (
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[360px] p-0">
<div className="flex max-h-[420px] flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="border-b border-divider-subtle p-2">
<div className="flex h-8 items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
<span className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder={t('access.members.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent system-sm-regular text-text-primary outline-none placeholder:text-text-quaternary"
/>
</div>
</div>
<div className="min-h-10 overflow-y-auto p-1">
{subjectsQuery.isLoading
? (
<div className="flex h-16 items-center justify-center">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
</div>
)
: subjects.length === 0
? (
<div className="px-3 py-5 text-center system-xs-regular text-text-tertiary">
{t('access.members.empty')}
</div>
)
: subjects.map((subject) => {
const isSelected = selectedKeys.has(subjectKey(subject))
const isGroup = subject.subjectType === 'group'
return (
<button
key={subjectKey(subject)}
type="button"
onClick={() => toggleSubject(subject)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left hover:bg-state-base-hover"
>
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-4 w-4 shrink-0 text-text-tertiary')} />
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
{subject.name || subject.id}
</span>
{isGroup && subject.memberCount != null && (
<span className="system-xs-regular text-text-tertiary">
{t('access.members.memberCount', { count: subject.memberCount })}
</span>
)}
{isSelected && (
<span className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</button>
)
})}
</div>
</div>
</PopoverContent>
)}
</Popover>
)
}
type EnvironmentPermissionRowProps = {
appId: string
environment: ConsoleEnvironmentSummary
summaryPolicy?: EffectivePolicySummary
onSetPolicy: (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
expectedVersion: number,
) => Promise<void>
}
export const EnvironmentPermissionRow: FC<EnvironmentPermissionRowProps> = ({
appId,
environment,
summaryPolicy,
onSetPolicy,
}) => {
const { t } = useTranslation('deployments')
const environmentId = environment.id
const channel = summaryPolicy?.channel ?? 'webapp'
const policyQuery = useQuery(consoleQuery.deployments.environmentAccessPolicy.queryOptions({
input: environmentId
? {
params: {
appId,
environmentId,
channel,
},
}
: skipToken,
staleTime: 30 * 1000,
}))
const detailPolicy = policyQuery.data?.policy
const policyKind = accessModeToPermissionKey(detailPolicy?.accessMode ?? summaryPolicy?.accessMode)
const policyFingerprint = [
detailPolicy?.id ?? 'new',
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
detailPolicy?.accessMode ?? summaryPolicy?.accessMode ?? '',
].join(':')
const policySelectedSubjects = useMemo(
() => policyKind === 'specific' ? selectedSubjectsFromPolicy(detailPolicy) : [],
[detailPolicy, policyKind],
)
const [draft, setDraft] = useState<{
fingerprint?: string
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const [isSaving, setIsSaving] = useState(false)
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
const persistPolicy = async (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
if (!environmentId)
return
if (nextKind === 'specific' && nextSubjects.length === 0)
return
setIsSaving(true)
try {
await onSetPolicy(
appId,
environmentId,
detailPolicy?.channel ?? channel,
detailPolicy?.enabled ?? summaryPolicy?.enabled ?? true,
permissionKeyToAccessMode(nextKind),
nextKind === 'specific' ? policySubjects(nextSubjects) : [],
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
)
await policyQuery.refetch()
setDraft({})
}
catch {
toast.error(t('access.permission.updateFailed'))
}
finally {
setIsSaving(false)
}
}
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
setDraft({
fingerprint: policyFingerprint,
kind: nextKind,
subjects: nextKind === 'specific' ? subjects : [],
})
if (nextKind === 'specific') {
void persistPolicy(nextKind, subjects)
return
}
void persistPolicy(nextKind, [])
}
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
if (nextSubjects.length === 0)
return
setDraft({
fingerprint: policyFingerprint,
kind: 'specific',
subjects: nextSubjects,
})
void persistPolicy('specific', nextSubjects)
}
return (
<div className="flex flex-col gap-1.5">
<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">
{environmentName(environment)}
</span>
<PermissionPicker
value={permissionKind}
disabled={isSaving || policyQuery.isLoading}
onChange={handlePermissionChange}
/>
</div>
{permissionKind === 'specific' && (
<div className="flex flex-col gap-2 pl-0 sm:pl-[152px]">
<div className="flex flex-wrap items-center gap-2">
<SubjectPicker
appId={appId}
selectedSubjects={subjects}
disabled={isSaving || policyQuery.isLoading}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
<span className="system-xs-regular text-text-tertiary">
{t('access.members.emptySelection')}
</span>
)}
</div>
{subjects.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{subjects.map(subject => (
<SubjectPill
key={subjectKey(subject)}
subject={subject}
disabled={isSaving || subjects.length <= 1}
onRemove={() => handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))}
/>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,10 @@
export function getUrlOrigin(url?: string) {
if (!url)
return undefined
try {
return new URL(url).origin
}
catch {
return url
}
}

View File

@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import type { EnvironmentDeploymentRow } from '@/contract/console/deployments'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -9,10 +8,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HealthBadge, ModeBadge } from '../components/status-badge'
import { useDeploymentsStore } from '../store'
import {
activeRelease,
@ -20,167 +17,18 @@ import {
deploymentId,
deploymentStatus,
environmentBackend,
environmentHealth,
environmentId,
environmentMode,
environmentName,
formatDate,
releaseCommit,
releaseLabel,
targetRelease,
} from '../utils'
import { DeploymentPanel } from './deploy-tab/deployment-panel'
import { DeploymentStatusSummary } from './deploy-tab/deployment-status-summary'
const GRID_TEMPLATE = 'lg:grid-cols-[1.2fr_0.8fr_1fr_auto]'
type InfoBlockProps = {
title: string
children: React.ReactNode
}
const InfoBlock: FC<InfoBlockProps> = ({ title, children }) => (
<div className="flex flex-col gap-1.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
<div className="flex flex-col gap-1">{children}</div>
</div>
)
type InfoRowProps = {
label: string
value: React.ReactNode
mono?: boolean
suffix?: string
}
const InfoRow: FC<InfoRowProps> = ({ label, value, mono, suffix }) => (
<div className="flex items-start gap-2 py-0.5">
<span className="w-24 shrink-0 system-xs-regular text-text-tertiary">{label}</span>
<span className={cn('min-w-0 flex-1 system-sm-regular break-words text-text-primary', mono && 'font-mono')}>
{value}
{suffix && <span className="system-xs-regular text-text-tertiary">{suffix}</span>}
</span>
</div>
)
type DeploymentPanelProps = {
row: EnvironmentDeploymentRow
}
const DeploymentPanel: FC<DeploymentPanelProps> = ({ row }) => {
const { t } = useTranslation('deployments')
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">
{environmentName(env)}
{' · '}
{releaseLabel(observed || pending)}
</span>
<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={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={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 />
)}
{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={row.observedRuntime?.endpoints?.run ?? '—'} mono />
<InfoRow label={t('deployTab.panel.health')} value={row.observedRuntime?.endpoints?.health ?? '—'} mono />
</InfoBlock>
{credentials.length > 0 && (
<InfoBlock title={t('deployTab.panel.modelCreds')}>
{credentials.map(c => (
<InfoRow
key={`${c.slot}-${c.displayName}-${c.maskedValue}`}
label={c.slot ?? '—'}
value={c.displayName || c.maskedValue || '—'}
mono
/>
))}
</InfoBlock>
)}
{envVars.length > 0 && (
<InfoBlock title={t('deployTab.panel.envVars')}>
{envVars.map(v => (
<InfoRow
key={`${v.slot}-${v.displayName}`}
label={v.slot ?? '—'}
value={v.maskedValue || v.displayName || '—'}
mono
/>
))}
</InfoBlock>
)}
</div>
{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">
{row.instance.lastError.message}
</div>
)}
</div>
)
}
type DeploymentStatusSummaryProps = {
row: EnvironmentDeploymentRow
}
const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ row }) => {
const { t } = useTranslation('deployments')
const status = deploymentStatus(row)
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: releaseLabel(targetRelease(row) || activeRelease(row)) })}
</span>
)
}
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" />
{t('deployTab.status.runningWithFailed')}
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
<span className="h-1.5 w-1.5 rounded-full bg-util-colors-green-green-500" />
{t('status.ready')}
</span>
)
}
type DeployTabProps = {
instanceId: string
}

View File

@ -0,0 +1,134 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { EnvironmentDeploymentRow } from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { HealthBadge, ModeBadge } from '../../components/status-badge'
import {
activeRelease,
deploymentId,
environmentBackend,
environmentHealth,
environmentMode,
environmentName,
formatDate,
releaseCommit,
releaseLabel,
targetRelease,
} from '../../utils'
type InfoBlockProps = {
title: string
children: ReactNode
}
const InfoBlock: FC<InfoBlockProps> = ({ title, children }) => (
<div className="flex flex-col gap-1.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
<div className="flex flex-col gap-1">{children}</div>
</div>
)
type InfoRowProps = {
label: string
value: ReactNode
mono?: boolean
suffix?: string
}
const InfoRow: FC<InfoRowProps> = ({ label, value, mono, suffix }) => (
<div className="flex items-start gap-2 py-0.5">
<span className="w-24 shrink-0 system-xs-regular text-text-tertiary">{label}</span>
<span className={cn('min-w-0 flex-1 system-sm-regular break-words text-text-primary', mono && 'font-mono')}>
{value}
{suffix && <span className="system-xs-regular text-text-tertiary">{suffix}</span>}
</span>
</div>
)
type DeploymentPanelProps = {
row: EnvironmentDeploymentRow
}
export const DeploymentPanel: FC<DeploymentPanelProps> = ({ row }) => {
const { t } = useTranslation('deployments')
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">
{environmentName(env)}
{' · '}
{releaseLabel(observed || pending)}
</span>
<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={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={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 />
)}
{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={row.observedRuntime?.endpoints?.run ?? '—'} mono />
<InfoRow label={t('deployTab.panel.health')} value={row.observedRuntime?.endpoints?.health ?? '—'} mono />
</InfoBlock>
{credentials.length > 0 && (
<InfoBlock title={t('deployTab.panel.modelCreds')}>
{credentials.map(c => (
<InfoRow
key={`${c.slot}-${c.displayName}-${c.maskedValue}`}
label={c.slot ?? '—'}
value={c.displayName || c.maskedValue || '—'}
mono
/>
))}
</InfoBlock>
)}
{envVars.length > 0 && (
<InfoBlock title={t('deployTab.panel.envVars')}>
{envVars.map(v => (
<InfoRow
key={`${v.slot}-${v.displayName}`}
label={v.slot ?? '—'}
value={v.maskedValue || v.displayName || '—'}
mono
/>
))}
</InfoBlock>
)}
</div>
{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">
{row.instance.lastError.message}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,45 @@
'use client'
import type { FC } from 'react'
import type { EnvironmentDeploymentRow } from '@/contract/console/deployments'
import { useTranslation } from 'react-i18next'
import {
activeRelease,
deploymentStatus,
releaseLabel,
targetRelease,
} from '../../utils'
type DeploymentStatusSummaryProps = {
row: EnvironmentDeploymentRow
}
export const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ row }) => {
const { t } = useTranslation('deployments')
const status = deploymentStatus(row)
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: releaseLabel(targetRelease(row) || activeRelease(row)) })}
</span>
)
}
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" />
{t('deployTab.status.runningWithFailed')}
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
<span className="h-1.5 w-1.5 rounded-full bg-util-colors-green-green-500" />
{t('status.ready')}
</span>
)
}

View File

@ -0,0 +1,200 @@
'use client'
import type { ComponentProps, FC, PropsWithoutRef } from 'react'
import type { AppInfo } from '../types'
import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { useHover, useKeyPress } from 'ahooks'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
type TabDef = {
key: InstanceDetailTabKey
icon: NavIcon
selectedIcon: NavIcon
}
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
title?: string
titleId?: string
}
const OverviewIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-dashboard-2-line', className)} />
const OverviewSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-dashboard-2-fill', className)} />
const DeployIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-rocket-line', className)} />
const DeploySelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-rocket-fill', className)} />
const VersionsIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-stack-line', className)} />
const VersionsSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-stack-fill', className)} />
const AccessIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-plug-line', className)} />
const AccessSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-plug-fill', className)} />
const SettingsIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
const SettingsSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-settings-3-fill', className)} />
const TABS: TabDef[] = [
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'versions', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
]
const isShortcutFromInputArea = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement))
return false
return target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.isContentEditable
}
type DeploymentSidebarProps = {
instanceId: string
instanceName: string
instanceDescription?: string
appModeLabel: string
app?: AppInfo
}
export const DeploymentSidebar: FC<DeploymentSidebarProps> = ({
instanceId,
instanceName,
instanceDescription,
appModeLabel,
app,
}) => {
const { t } = useTranslation('deployments')
const sidebarRef = useRef<HTMLDivElement>(null)
const isHoveringSidebar = useHover(sidebarRef)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const sidebarMode = appSidebarExpand || 'expand'
const expand = sidebarMode === 'expand'
const handleToggle = useCallback(() => {
setAppSidebarExpand(sidebarMode === 'expand' ? 'collapse' : 'expand')
}, [setAppSidebarExpand, sidebarMode])
useEffect(() => {
const persistedMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
setAppSidebarExpand(isMobile ? 'collapse' : persistedMode)
}, [isMobile, setAppSidebarExpand])
useEffect(() => {
if (appSidebarExpand)
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
}, [appSidebarExpand])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
return
e.preventDefault()
handleToggle()
}, { exactMatch: true, useCapture: true })
return (
<aside
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-[216px]' : 'w-14',
)}
>
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
<div className="flex items-center gap-1">
{app
? (
<AppIcon
size={expand ? 'large' : 'medium'}
iconType={app.iconType}
icon={app.icon}
background={app.iconBackground}
imageUrl={app.iconUrl}
/>
)
: (
<div className={cn(
'flex items-center justify-center rounded-xl border border-divider-subtle bg-background-default text-text-tertiary',
expand ? 'h-10 w-10' : 'h-8 w-8',
)}
>
<span aria-hidden className="i-ri-apps-2-line h-5 w-5" />
</div>
)}
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
{instanceName}
</div>
</div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{appModeLabel}
</div>
{instanceDescription && (
<div
className="line-clamp-2 system-xs-regular text-text-tertiary"
title={instanceDescription}
>
{instanceDescription}
</div>
)}
</div>
)}
</div>
</div>
<div className="relative px-4 py-2">
<Divider
type="horizontal"
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className="absolute top-[-3.5px] -right-3 z-20"
expand={expand}
handleToggle={handleToggle}
/>
)}
</div>
<nav
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{TABS.map(tab => (
<NavLink
key={tab.key}
mode={sidebarMode}
iconMap={{ selected: tab.selectedIcon, normal: tab.icon }}
name={t(`tabs.${tab.key}.name`)}
href={`/deployments/${instanceId}/${tab.key}`}
/>
))}
</nav>
</aside>
)
}

View File

@ -1,22 +1,11 @@
'use client'
import type { ComponentProps, FC, PropsWithoutRef, ReactNode } from 'react'
import type { AppInfo } from '../types'
import type { FC, ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useHover, useKeyPress } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
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 DeployDrawer from '../components/deploy-drawer'
@ -25,195 +14,14 @@ import { useDeploymentData } from '../hooks/use-deployment-data'
import { useSourceApps } from '../hooks/use-source-apps'
import { useDeploymentsStore } from '../store'
import { deployedRows, deploymentStatus } from '../utils'
import { DeploymentSidebar } from './deployment-sidebar'
import { isInstanceDetailTabKey } from './tabs'
type TabDef = {
key: InstanceDetailTabKey
icon: NavIcon
selectedIcon: NavIcon
}
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
title?: string
titleId?: string
}
const OverviewIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-dashboard-2-line', className)} />
const OverviewSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-dashboard-2-fill', className)} />
const DeployIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-rocket-line', className)} />
const DeploySelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-rocket-fill', className)} />
const VersionsIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-stack-line', className)} />
const VersionsSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-stack-fill', className)} />
const AccessIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-plug-line', className)} />
const AccessSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-plug-fill', className)} />
const SettingsIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
const SettingsSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-settings-3-fill', className)} />
const TABS: TabDef[] = [
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'versions', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
]
type InstanceDetailProps = {
instanceId: string
children: ReactNode
}
const isShortcutFromInputArea = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement))
return false
return target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.isContentEditable
}
type DeploymentSidebarProps = {
instanceId: string
instanceName: string
instanceDescription?: string
appModeLabel: string
app?: AppInfo
}
const DeploymentSidebar: FC<DeploymentSidebarProps> = ({
instanceId,
instanceName,
instanceDescription,
appModeLabel,
app,
}) => {
const { t } = useTranslation('deployments')
const sidebarRef = useRef<HTMLDivElement>(null)
const isHoveringSidebar = useHover(sidebarRef)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const sidebarMode = appSidebarExpand || 'expand'
const expand = sidebarMode === 'expand'
const handleToggle = useCallback(() => {
setAppSidebarExpand(sidebarMode === 'expand' ? 'collapse' : 'expand')
}, [setAppSidebarExpand, sidebarMode])
useEffect(() => {
const persistedMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
setAppSidebarExpand(isMobile ? 'collapse' : persistedMode)
}, [isMobile, setAppSidebarExpand])
useEffect(() => {
if (appSidebarExpand)
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
}, [appSidebarExpand])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
return
e.preventDefault()
handleToggle()
}, { exactMatch: true, useCapture: true })
return (
<aside
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-[216px]' : 'w-14',
)}
>
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
<div className="flex items-center gap-1">
{app
? (
<AppIcon
size={expand ? 'large' : 'medium'}
iconType={app.iconType}
icon={app.icon}
background={app.iconBackground}
imageUrl={app.iconUrl}
/>
)
: (
<div className={cn(
'flex items-center justify-center rounded-xl border border-divider-subtle bg-background-default text-text-tertiary',
expand ? 'h-10 w-10' : 'h-8 w-8',
)}
>
<span aria-hidden className="i-ri-apps-2-line h-5 w-5" />
</div>
)}
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
{instanceName}
</div>
</div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{appModeLabel}
</div>
{instanceDescription && (
<div
className="line-clamp-2 system-xs-regular text-text-tertiary"
title={instanceDescription}
>
{instanceDescription}
</div>
)}
</div>
)}
</div>
</div>
<div className="relative px-4 py-2">
<Divider
type="horizontal"
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className="absolute top-[-3.5px] -right-3 z-20"
expand={expand}
handleToggle={handleToggle}
/>
)}
</div>
<nav
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{TABS.map(tab => (
<NavLink
key={tab.key}
mode={sidebarMode}
iconMap={{ selected: tab.selectedIcon, normal: tab.icon }}
name={t(`tabs.${tab.key}.name`)}
href={`/deployments/${instanceId}/${tab.key}`}
/>
))}
</nav>
</aside>
)
}
const InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation()

View File

@ -1,179 +1,22 @@
'use client'
import type { FC } from 'react'
import type { DeployedToSummary, ReleaseHistoryRow } from '@/contract/console/deployments'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDeploymentsStore } from '../store'
import {
activeRelease,
deployedRows,
deploymentId,
deploymentStatus,
environmentId,
environmentName,
formatDate,
releaseCommit,
releaseLabel,
targetRelease,
} from '../utils'
import { DeployReleaseMenu } from './versions-tab/deploy-release-menu'
import { DeployedToBadge } from './versions-tab/deployed-to-badge'
import { getReleaseDeployments } from './versions-tab/release-deployments'
const GRID_TEMPLATE = 'grid-cols-[0.9fr_1fr_0.8fr_1.5fr_auto]'
type ReleaseDeploymentState = 'active' | 'deploying' | 'failed'
type ReleaseDeployment = {
environmentId: string
environmentName: string
state: ReleaseDeploymentState
}
const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
active: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
failed: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
}
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'
}
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 appData = useDeploymentsStore(state => state.appData[appId])
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const openRollbackModal = useDeploymentsStore(state => state.openRollbackModal)
const [open, setOpen] = useState(false)
const environments = appData?.candidates.environmentOptions?.filter(env => env.id) ?? []
const deploymentRows = deployedRows(appData?.environmentDeployments.environmentDeployments)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'inline-flex h-7 items-center gap-1 rounded-md px-2 system-xs-medium',
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-accent-text',
'hover:bg-components-button-secondary-bg-hover',
)}
>
{t('versions.deploy')}
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
{environments.map((env) => {
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={envId}
className="gap-2 px-3"
disabled={disabled}
onClick={() => {
setOpen(false)
if (disabled)
return
if (row) {
openRollbackModal({
appId,
environmentId: envId,
deploymentId: deploymentId(row),
targetReleaseId: releaseId,
})
return
}
openDeployDrawer({ appId, environmentId: envId, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{isEnvironmentDeploying
? t('versions.deployingTo', { name: environmentName(env) })
: isCurrent
? t('versions.currentOn', { name: environmentName(env) })
: row
? t('versions.promoteTo', { name: environmentName(env) })
: t('versions.deployTo', { name: environmentName(env) })}
</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
const DeployedToBadge: FC<{ item: ReleaseDeployment }> = ({ item }) => {
const { t } = useTranslation('deployments')
const statusLabel = t(`versions.deployedStatus.${item.state}`)
return (
<Tooltip>
<TooltipTrigger
render={(
<span
className={cn(
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 system-xs-medium',
RELEASE_DEPLOYMENT_STYLES[item.state],
)}
>
{item.state === 'deploying'
? <span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
: item.state === 'failed'
? <span className="i-ri-alert-line h-3.5 w-3.5" />
: <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{item.environmentName}
</span>
)}
/>
<TooltipContent>
{statusLabel}
{' · '}
{item.environmentName}
</TooltipContent>
</Tooltip>
)
}
type VersionsTabProps = {
instanceId: string
}
@ -190,45 +33,6 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
[appData?.environmentDeployments.environmentDeployments],
)
const getReleaseDeployments = (row: ReleaseHistoryRow) => {
const releaseId = row.release?.id
if (!releaseId)
return []
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 (activeRelease(deployment)?.id === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'active',
})
}
if (targetRelease(deployment)?.id === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'deploying',
})
}
if (deployment.instance?.lastError?.releaseId === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'failed',
})
}
return items
})
return dedupeReleaseDeployments([...historyItems, ...runtimeItems])
}
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center justify-between">
@ -265,7 +69,7 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
{releaseRows.map((row) => {
const release = row.release!
const releaseDeployments = getReleaseDeployments(row)
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
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">

View File

@ -0,0 +1,95 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDeploymentsStore } from '../../store'
import {
activeRelease,
deployedRows,
deploymentId,
deploymentStatus,
environmentId,
environmentName,
} from '../../utils'
type DeployReleaseMenuProps = {
appId: string
releaseId: string
}
export const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ appId, releaseId }) => {
const { t } = useTranslation('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 environments = appData?.candidates.environmentOptions?.filter(env => env.id) ?? []
const deploymentRows = deployedRows(appData?.environmentDeployments.environmentDeployments)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'inline-flex h-7 items-center gap-1 rounded-md px-2 system-xs-medium',
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-accent-text',
'hover:bg-components-button-secondary-bg-hover',
)}
>
{t('versions.deploy')}
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
{environments.map((env) => {
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={envId}
className="gap-2 px-3"
disabled={disabled}
onClick={() => {
setOpen(false)
if (disabled)
return
if (row) {
openRollbackModal({
appId,
environmentId: envId,
deploymentId: deploymentId(row),
targetReleaseId: releaseId,
})
return
}
openDeployDrawer({ appId, environmentId: envId, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{isEnvironmentDeploying
? t('versions.deployingTo', { name: environmentName(env) })
: isCurrent
? t('versions.currentOn', { name: environmentName(env) })
: row
? t('versions.promoteTo', { name: environmentName(env) })
: t('versions.deployTo', { name: environmentName(env) })}
</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -0,0 +1,49 @@
'use client'
import type { FC } from 'react'
import type { ReleaseDeployment, ReleaseDeploymentState } from './release-deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
active: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
failed: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
}
type DeployedToBadgeProps = {
item: ReleaseDeployment
}
export const DeployedToBadge: FC<DeployedToBadgeProps> = ({ item }) => {
const { t } = useTranslation('deployments')
const statusLabel = t(`versions.deployedStatus.${item.state}`)
return (
<Tooltip>
<TooltipTrigger
render={(
<span
className={cn(
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 system-xs-medium',
RELEASE_DEPLOYMENT_STYLES[item.state],
)}
>
{item.state === 'deploying'
? <span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
: item.state === 'failed'
? <span className="i-ri-alert-line h-3.5 w-3.5" />
: <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{item.environmentName}
</span>
)}
/>
<TooltipContent>
{statusLabel}
{' · '}
{item.environmentName}
</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,81 @@
import type { DeployedToSummary, EnvironmentDeploymentRow, ReleaseHistoryRow } from '@/contract/console/deployments'
import {
activeRelease,
environmentId,
environmentName,
targetRelease,
} from '../../utils'
export type ReleaseDeploymentState = 'active' | 'deploying' | 'failed'
export type ReleaseDeployment = {
environmentId: string
environmentName: string
state: ReleaseDeploymentState
}
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'
}
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
})
}
export function getReleaseDeployments(row: ReleaseHistoryRow, deploymentRows: EnvironmentDeploymentRow[]) {
const releaseId = row.release?.id
if (!releaseId)
return []
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 (activeRelease(deployment)?.id === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'active',
})
}
if (targetRelease(deployment)?.id === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'deploying',
})
}
if (deployment.instance?.lastError?.releaseId === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: 'failed',
})
}
return items
})
return dedupeReleaseDeployments([...historyItems, ...runtimeItems])
}

View File

@ -3,7 +3,7 @@
import type { AppInfo } from '../types'
import { useQueries } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import { deploymentAppDataQueryOptions } from '@/service/deployments'
import { deploymentAppDataQueryOptions } from '../data'
import { useDeploymentsStore } from '../store'
type UseDeploymentDataOptions = {

View File

@ -0,0 +1,86 @@
'use client'
import type { FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
export type EnvironmentFilterOption = {
value: string
text: string
icon: ReactNode
disabled?: boolean
disabledReason?: string
}
type EnvironmentFilterProps = {
value: string
options: EnvironmentFilterOption[]
onChange: (value: string) => void
}
export const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ value, options, onChange }) => {
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === value) ?? options[0]
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left select-none',
open && 'shadow-xs',
)}
>
<div className="p-px text-text-tertiary">
{selectedOption?.icon}
</div>
<div className="max-w-[160px] min-w-0 truncate text-[13px] leading-[18px] text-text-secondary">
{selectedOption?.text}
</div>
<div className="shrink-0 p-px">
<span className={cn('i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} />
</div>
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<div className="max-h-72 overflow-auto p-1">
{options.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
if (option.disabled)
return
onChange(option.value)
setOpen(false)
}}
title={option.disabled ? option.disabledReason : undefined}
aria-disabled={option.disabled}
className={cn(
'flex items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none',
option.disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-state-base-hover',
)}
>
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
<span className="grow truncate text-sm leading-5 text-text-tertiary">{option.text}</span>
{option.value === value && (
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-secondary" />
)}
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -1,448 +1,20 @@
'use client'
import type { FC } from 'react'
import type { AppInfo } from '../types'
import type { AppDeploymentSummary } from '@/contract/console/deployments'
import type { DeploymentAppData } from '@/service/deployments'
import type { AppModeEnum } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useDebounceFn } from 'ahooks'
import { parseAsString, useQueryState } from 'nuqs'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
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 CreateInstanceModal from '../components/create-instance-modal'
import DeployDrawer from '../components/deploy-drawer'
import RollbackModal from '../components/rollback-modal'
import { useSourceApps } from '../hooks/use-source-apps'
import { useDeploymentsStore } from '../store'
import { deployedRows, deploymentStatus, environmentId, environmentName, releaseLabel } from '../utils'
type NewInstanceCardProps = {
onOpen: () => void
}
type NewInstanceActionProps = {
icon: string
label: string
disabled?: boolean
onClick?: () => void
}
const NewInstanceAction: FC<NewInstanceActionProps> = ({ icon, label, disabled, onClick }) => {
const { t } = useTranslation('deployments')
return (
<button
type="button"
onClick={disabled ? undefined : onClick}
disabled={disabled}
title={disabled ? t('newInstance.comingSoon') : undefined}
className={cn(
'mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-left text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
disabled
? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-text-tertiary'
: 'cursor-pointer',
)}
>
<span aria-hidden className={cn('mr-2 h-4 w-4 shrink-0', icon)} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{disabled && (
<span className="ml-2 shrink-0 rounded-md bg-state-base-hover px-1.5 system-2xs-medium text-text-tertiary">
{t('newInstance.comingSoon')}
</span>
)}
</button>
)
}
const NewInstanceCard: FC<NewInstanceCardProps> = ({ onOpen }) => {
const { t } = useTranslation('deployments')
return (
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pt-2 pb-1 text-xs leading-[18px] font-medium text-text-tertiary">
{t('newInstance.title')}
</div>
<NewInstanceAction
icon="i-ri-stack-line"
label={t('newInstance.fromStudio')}
onClick={onOpen}
/>
<NewInstanceAction
icon="i-ri-github-fill"
label={t('newInstance.fromGitHub')}
disabled
/>
<NewInstanceAction
icon="i-ri-file-code-line"
label={t('newInstance.importDSL')}
disabled
/>
</div>
</div>
)
}
type InstanceCardProps = {
app: AppInfo
appData?: DeploymentAppData
summary?: AppDeploymentSummary
}
const InstanceCard: FC<InstanceCardProps> = ({ app, appData, summary }) => {
const { t } = useTranslation('deployments')
const router = useRouter()
const { formatTimeFromNow } = useFormatTimeFromNow()
const [menuOpen, setMenuOpen] = useState(false)
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`)
const handleMenuAction = (e: React.MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
e.preventDefault()
setMenuOpen(false)
action()
}
const deployments = useMemo(
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
[appData?.environmentDeployments.environmentDeployments],
)
const statusCount = (status: string) =>
summary?.statusCounts?.find(item => item.status === status)?.count ?? 0
const hasSummary = Boolean(summary)
const failedCount = hasSummary
? statusCount('failed') + statusCount('deploy_failed')
: deployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
const deployingCount = hasSummary
? statusCount('deploying')
: deployments.filter(row => deploymentStatus(row) === 'deploying').length
const readyCount = hasSummary
? statusCount('ready')
: deployments.filter(row => deploymentStatus(row) === 'ready').length
const envCount = hasSummary
? (summary?.deployed ? failedCount + deployingCount + readyCount : 0)
: deployments.length
const lastDeployedAt = useMemo(() => {
if (summary?.lastDeployedAt)
return new Date(summary.lastDeployedAt).getTime()
if (deployments.length === 0)
return null
return deployments.reduce((latest, row) => {
const t = new Date(row.instance?.lastDeployedAt || row.instance?.lastReadyAt || '').getTime()
return t > latest ? t : latest
}, 0)
}, [deployments, summary?.lastDeployedAt])
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
? 'none'
: failedCount > 0
? 'failed'
: deployingCount > 0
? 'deploying'
: 'ready'
const primaryText = primaryStatus === 'none'
? t('card.notDeployed')
: primaryStatus === 'failed'
? t('card.failed', { count: failedCount })
: primaryStatus === 'deploying'
? t('card.deploying', { count: deployingCount })
: t('card.ready', { count: readyCount })
const secondaryParts: string[] = []
if (primaryStatus === 'failed' && deployingCount > 0)
secondaryParts.push(t('card.deploying', { count: deployingCount }))
if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0)
secondaryParts.push(t('card.ready', { count: readyCount }))
const statusLabel = (status: ReturnType<typeof deploymentStatus>) => {
if (status === 'deploy_failed')
return t('status.deployFailed')
return t(`status.${status}`)
}
const statusSummaryLabel = (status?: string) => {
if (status === 'failed' || status === 'deploy_failed')
return t('status.deployFailed')
if (status === 'deploying')
return t('status.deploying')
if (status === 'ready')
return t('status.ready')
return status || 'unknown'
}
const statusSummaryTooltip = summary?.statusCounts?.filter(item => item.count && item.status !== 'undeployed') ?? []
const statusTooltip = primaryStatus === 'none'
? t('card.tooltip.notDeployed')
: deployments.length > 0
? (
<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 status = deploymentStatus(deployment)
return (
<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">
{environmentName(deployment.environment)}
</span>
<span className="shrink-0 text-text-secondary">
{statusLabel(status)}
{' · '}
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
</span>
</div>
)
})}
</div>
)
: (
<div className="flex min-w-[180px] flex-col gap-1">
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
{statusSummaryTooltip.map(item => (
<div key={item.status} className="flex justify-between gap-3">
<span className="text-text-tertiary">{statusSummaryLabel(item.status)}</span>
<span className="text-text-secondary">{item.count}</span>
</div>
))}
</div>
)
const healthPillClass = primaryStatus === 'none'
? 'text-text-tertiary bg-background-section-burn'
: primaryStatus === 'failed'
? 'text-util-colors-red-red-700 bg-util-colors-red-red-50'
: primaryStatus === 'deploying'
? 'text-util-colors-warning-warning-700 bg-util-colors-warning-warning-50'
: 'text-util-colors-green-green-700 bg-util-colors-green-green-50'
const healthDotClass = primaryStatus === 'none'
? 'bg-text-quaternary'
: primaryStatus === 'failed'
? 'bg-util-colors-red-red-500'
: primaryStatus === 'deploying'
? 'bg-util-colors-warning-warning-500 animate-pulse'
: 'bg-util-colors-green-green-500'
const appModeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
return (
<div
onClick={(e) => {
e.preventDefault()
navigateToDetail()
}}
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg"
>
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.iconType}
icon={app.icon}
background={app.iconBackground}
imageUrl={app.iconUrl}
/>
<AppTypeIcon
type={app.mode as unknown as AppModeEnum}
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
className="h-3 w-3"
/>
</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={app.name}>{app.name}</div>
</div>
<div className="truncate text-[10px] leading-[18px] font-medium text-text-tertiary" title={appModeLabel}>
{appModeLabel}
</div>
</div>
</div>
<div className="flex grow flex-col gap-2 px-[14px]">
<Tooltip>
<TooltipTrigger
render={(
<div className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
'inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 system-xs-medium',
healthPillClass,
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', healthDotClass)} />
{primaryText}
</span>
{secondaryParts.length > 0 && (
<span className="truncate system-xs-regular text-text-tertiary">
{secondaryParts.join(' · ')}
</span>
)}
</div>
)}
/>
<TooltipContent>{statusTooltip}</TooltipContent>
</Tooltip>
<div className="flex min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-apps-2-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
<span className="truncate" title={app.name}>
{t('card.fromApp', { name: app.name })}
</span>
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
<div className="mr-[41px] flex min-w-0 grow items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-time-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
<span className="truncate">
{lastDeployedAt
? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) })
: t('card.neverDeployed')}
</span>
</div>
<div
className={cn(
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
menuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t('card.moreActions')}
className={cn(
menuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
{menuOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[216px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))}
>
<span className="system-sm-regular text-text-secondary">{t('card.menu.deploy')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={e => handleMenuAction(e, navigateToDetail)}
>
<span className="system-sm-regular text-text-secondary">{t('card.menu.viewDetail')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
aria-disabled
title={t('card.menu.deleteDisabled')}
className="cursor-not-allowed gap-2 px-3 opacity-50"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span className="system-sm-regular text-text-destructive">{t('card.menu.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
</div>
</div>
</div>
)
}
type EnvironmentFilterOption = {
value: string
text: string
icon: React.ReactNode
disabled?: boolean
disabledReason?: string
}
type EnvironmentFilterProps = {
value: string
options: EnvironmentFilterOption[]
onChange: (value: string) => void
}
const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ value, options, onChange }) => {
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === value) ?? options[0]
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left select-none',
open && 'shadow-xs',
)}
>
<div className="p-px text-text-tertiary">
{selectedOption?.icon}
</div>
<div className="max-w-[160px] min-w-0 truncate text-[13px] leading-[18px] text-text-secondary">
{selectedOption?.text}
</div>
<div className="shrink-0 p-px">
<span className={cn('i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} />
</div>
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<div className="max-h-72 overflow-auto p-1">
{options.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
if (option.disabled)
return
onChange(option.value)
setOpen(false)
}}
title={option.disabled ? option.disabledReason : undefined}
aria-disabled={option.disabled}
className={cn(
'flex items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none',
option.disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-state-base-hover',
)}
>
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
<span className="grow truncate text-sm leading-5 text-text-tertiary">{option.text}</span>
{option.value === value && (
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-secondary" />
)}
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
import { environmentId, environmentName } from '../utils'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
import { NewInstanceCard } from './new-instance-card'
const DeploymentsMain: FC = () => {
const { t } = useTranslation('deployments')
@ -547,16 +119,14 @@ 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((app) => {
return (
<InstanceCard
key={app.id}
app={app}
appData={appData[app.id]}
summary={summaries[app.id]}
/>
)
})}
{visibleInstances.map(app => (
<InstanceCard
key={app.id}
app={app}
appData={appData[app.id]}
summary={summaries[app.id]}
/>
))}
</div>
<div className="py-4" />

View File

@ -0,0 +1,297 @@
'use client'
import type { FC, MouseEvent } from 'react'
import type { DeploymentAppData } from '../data'
import type { AppInfo } from '../types'
import type { AppDeploymentSummary } from '@/contract/console/deployments'
import type { AppModeEnum } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useRouter } from '@/next/navigation'
import { useDeploymentsStore } from '../store'
import { deployedRows, deploymentStatus, environmentId, environmentName, releaseLabel } from '../utils'
type InstanceCardProps = {
app: AppInfo
appData?: DeploymentAppData
summary?: AppDeploymentSummary
}
export const InstanceCard: FC<InstanceCardProps> = ({ app, appData, summary }) => {
const { t } = useTranslation('deployments')
const router = useRouter()
const { formatTimeFromNow } = useFormatTimeFromNow()
const [menuOpen, setMenuOpen] = useState(false)
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`)
const handleMenuAction = (e: MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
e.preventDefault()
setMenuOpen(false)
action()
}
const deployments = useMemo(
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
[appData?.environmentDeployments.environmentDeployments],
)
const statusCount = (status: string) =>
summary?.statusCounts?.find(item => item.status === status)?.count ?? 0
const hasSummary = Boolean(summary)
const failedCount = hasSummary
? statusCount('failed') + statusCount('deploy_failed')
: deployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
const deployingCount = hasSummary
? statusCount('deploying')
: deployments.filter(row => deploymentStatus(row) === 'deploying').length
const readyCount = hasSummary
? statusCount('ready')
: deployments.filter(row => deploymentStatus(row) === 'ready').length
const envCount = hasSummary
? (summary?.deployed ? failedCount + deployingCount + readyCount : 0)
: deployments.length
const lastDeployedAt = useMemo(() => {
if (summary?.lastDeployedAt)
return new Date(summary.lastDeployedAt).getTime()
if (deployments.length === 0)
return null
return deployments.reduce((latest, row) => {
const t = new Date(row.instance?.lastDeployedAt || row.instance?.lastReadyAt || '').getTime()
return t > latest ? t : latest
}, 0)
}, [deployments, summary?.lastDeployedAt])
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
? 'none'
: failedCount > 0
? 'failed'
: deployingCount > 0
? 'deploying'
: 'ready'
const primaryText = primaryStatus === 'none'
? t('card.notDeployed')
: primaryStatus === 'failed'
? t('card.failed', { count: failedCount })
: primaryStatus === 'deploying'
? t('card.deploying', { count: deployingCount })
: t('card.ready', { count: readyCount })
const secondaryParts: string[] = []
if (primaryStatus === 'failed' && deployingCount > 0)
secondaryParts.push(t('card.deploying', { count: deployingCount }))
if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0)
secondaryParts.push(t('card.ready', { count: readyCount }))
const statusLabel = (status: ReturnType<typeof deploymentStatus>) => {
if (status === 'deploy_failed')
return t('status.deployFailed')
return t(`status.${status}`)
}
const statusSummaryLabel = (status?: string) => {
if (status === 'failed' || status === 'deploy_failed')
return t('status.deployFailed')
if (status === 'deploying')
return t('status.deploying')
if (status === 'ready')
return t('status.ready')
return status || 'unknown'
}
const statusSummaryTooltip = summary?.statusCounts?.filter(item => item.count && item.status !== 'undeployed') ?? []
const statusTooltip = primaryStatus === 'none'
? t('card.tooltip.notDeployed')
: deployments.length > 0
? (
<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 status = deploymentStatus(deployment)
return (
<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">
{environmentName(deployment.environment)}
</span>
<span className="shrink-0 text-text-secondary">
{statusLabel(status)}
{' · '}
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
</span>
</div>
)
})}
</div>
)
: (
<div className="flex min-w-[180px] flex-col gap-1">
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
{statusSummaryTooltip.map(item => (
<div key={item.status} className="flex justify-between gap-3">
<span className="text-text-tertiary">{statusSummaryLabel(item.status)}</span>
<span className="text-text-secondary">{item.count}</span>
</div>
))}
</div>
)
const healthPillClass = primaryStatus === 'none'
? 'text-text-tertiary bg-background-section-burn'
: primaryStatus === 'failed'
? 'text-util-colors-red-red-700 bg-util-colors-red-red-50'
: primaryStatus === 'deploying'
? 'text-util-colors-warning-warning-700 bg-util-colors-warning-warning-50'
: 'text-util-colors-green-green-700 bg-util-colors-green-green-50'
const healthDotClass = primaryStatus === 'none'
? 'bg-text-quaternary'
: primaryStatus === 'failed'
? 'bg-util-colors-red-red-500'
: primaryStatus === 'deploying'
? 'bg-util-colors-warning-warning-500 animate-pulse'
: 'bg-util-colors-green-green-500'
const appModeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
return (
<div
onClick={(e) => {
e.preventDefault()
navigateToDetail()
}}
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg"
>
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.iconType}
icon={app.icon}
background={app.iconBackground}
imageUrl={app.iconUrl}
/>
<AppTypeIcon
type={app.mode as unknown as AppModeEnum}
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
className="h-3 w-3"
/>
</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={app.name}>{app.name}</div>
</div>
<div className="truncate text-[10px] leading-[18px] font-medium text-text-tertiary" title={appModeLabel}>
{appModeLabel}
</div>
</div>
</div>
<div className="flex grow flex-col gap-2 px-[14px]">
<Tooltip>
<TooltipTrigger
render={(
<div className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
'inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 system-xs-medium',
healthPillClass,
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', healthDotClass)} />
{primaryText}
</span>
{secondaryParts.length > 0 && (
<span className="truncate system-xs-regular text-text-tertiary">
{secondaryParts.join(' · ')}
</span>
)}
</div>
)}
/>
<TooltipContent>{statusTooltip}</TooltipContent>
</Tooltip>
<div className="flex min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-apps-2-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
<span className="truncate" title={app.name}>
{t('card.fromApp', { name: app.name })}
</span>
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
<div className="mr-[41px] flex min-w-0 grow items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-time-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
<span className="truncate">
{lastDeployedAt
? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) })
: t('card.neverDeployed')}
</span>
</div>
<div
className={cn(
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
menuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t('card.moreActions')}
className={cn(
menuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
{menuOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[216px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))}
>
<span className="system-sm-regular text-text-secondary">{t('card.menu.deploy')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={e => handleMenuAction(e, navigateToDetail)}
>
<span className="system-sm-regular text-text-secondary">{t('card.menu.viewDetail')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
aria-disabled
title={t('card.menu.deleteDisabled')}
className="cursor-not-allowed gap-2 px-3 opacity-50"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span className="system-sm-regular text-text-destructive">{t('card.menu.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,71 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type NewInstanceCardProps = {
onOpen: () => void
}
type NewInstanceActionProps = {
icon: string
label: string
disabled?: boolean
onClick?: () => void
}
const NewInstanceAction: FC<NewInstanceActionProps> = ({ icon, label, disabled, onClick }) => {
const { t } = useTranslation('deployments')
return (
<button
type="button"
onClick={disabled ? undefined : onClick}
disabled={disabled}
title={disabled ? t('newInstance.comingSoon') : undefined}
className={cn(
'mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-left text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
disabled
? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-text-tertiary'
: 'cursor-pointer',
)}
>
<span aria-hidden className={cn('mr-2 h-4 w-4 shrink-0', icon)} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{disabled && (
<span className="ml-2 shrink-0 rounded-md bg-state-base-hover px-1.5 system-2xs-medium text-text-tertiary">
{t('newInstance.comingSoon')}
</span>
)}
</button>
)
}
export const NewInstanceCard: FC<NewInstanceCardProps> = ({ onOpen }) => {
const { t } = useTranslation('deployments')
return (
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pt-2 pb-1 text-xs leading-[18px] font-medium text-text-tertiary">
{t('newInstance.title')}
</div>
<NewInstanceAction
icon="i-ri-stack-line"
label={t('newInstance.fromStudio')}
onClick={onOpen}
/>
<NewInstanceAction
icon="i-ri-github-fill"
label={t('newInstance.fromGitHub')}
disabled
/>
<NewInstanceAction
icon="i-ri-file-code-line"
label={t('newInstance.importDSL')}
disabled
/>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import type { DeploymentAppData } from './data'
import type { AppInfo } from './types'
import type { AccessSubject, APIToken, BindingsProto } from '@/contract/console/deployments'
import type { DeploymentAppData } from '@/service/deployments'
import { create } from 'zustand'
import {
cancelDeployment,
@ -12,7 +12,7 @@ import {
rollbackEnvironment,
undeployEnvironment,
updateEnvironmentAccessPolicy,
} from '@/service/deployments'
} from './data'
export type StartDeployParams = {
appId: string