feat: init

This commit is contained in:
Stephen Zhou 2026-04-27 15:20:14 +08:00
parent 2677d90860
commit 46e7b5a85a
No known key found for this signature in database
32 changed files with 5473 additions and 0 deletions

2
.gitignore vendored
View File

@ -249,3 +249,5 @@ scripts/stress-test/reports/
.qoder/*
.eslintcache
temp

View File

@ -0,0 +1,12 @@
import AccessTab from '@/app/components/deployments/instance-detail/access-tab'
type PageProps = {
params: Promise<{ instanceId: string }>
}
const InstanceDetailAccessPage = async ({ params }: PageProps) => {
const { instanceId } = await params
return <AccessTab instanceId={instanceId} />
}
export default InstanceDetailAccessPage

View File

@ -0,0 +1,12 @@
import DeployTab from '@/app/components/deployments/instance-detail/deploy-tab'
type PageProps = {
params: Promise<{ instanceId: string }>
}
const InstanceDetailDeployPage = async ({ params }: PageProps) => {
const { instanceId } = await params
return <DeployTab instanceId={instanceId} />
}
export default InstanceDetailDeployPage

View File

@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import InstanceDetail from '@/app/components/deployments/instance-detail'
type LayoutProps = {
children: ReactNode
params: Promise<{ instanceId: string }>
}
const InstanceDetailLayout = async ({ children, params }: LayoutProps) => {
const { instanceId } = await params
return (
<InstanceDetail instanceId={instanceId}>
{children}
</InstanceDetail>
)
}
export default InstanceDetailLayout

View File

@ -0,0 +1,23 @@
'use client'
import type { FC } from 'react'
import type { InstanceDetailTabKey } from '@/app/components/deployments/instance-detail/tabs'
import * as React from 'react'
import { use } from 'react'
import OverviewTab from '@/app/components/deployments/instance-detail/overview-tab'
import { useRouter } from '@/next/navigation'
type PageProps = {
params: Promise<{ instanceId: string }>
}
const InstanceDetailOverviewPage: FC<PageProps> = ({ params }) => {
const { instanceId } = use(params)
const router = useRouter()
const handleSwitchTab = (tab: InstanceDetailTabKey) => {
router.push(`/deployments/${instanceId}/${tab}`)
}
return <OverviewTab instanceId={instanceId} onSwitchTab={handleSwitchTab} />
}
export default InstanceDetailOverviewPage

View File

@ -0,0 +1,12 @@
import { redirect } from '@/next/navigation'
type PageProps = {
params: Promise<{ instanceId: string }>
}
const InstanceDetailPage = async ({ params }: PageProps) => {
const { instanceId } = await params
redirect(`/deployments/${instanceId}/overview`)
}
export default InstanceDetailPage

View File

@ -0,0 +1,12 @@
import SettingsTab from '@/app/components/deployments/instance-detail/settings-tab'
type PageProps = {
params: Promise<{ instanceId: string }>
}
const InstanceDetailSettingsPage = async ({ params }: PageProps) => {
const { instanceId } = await params
return <SettingsTab instanceId={instanceId} />
}
export default InstanceDetailSettingsPage

View File

@ -0,0 +1,12 @@
import VersionsTab from '@/app/components/deployments/instance-detail/versions-tab'
type PageProps = {
params: Promise<{ instanceId: string }>
}
const InstanceDetailVersionsPage = async ({ params }: PageProps) => {
const { instanceId } = await params
return <VersionsTab instanceId={instanceId} />
}
export default InstanceDetailVersionsPage

View File

@ -0,0 +1,13 @@
'use client'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import DeploymentsMain from '@/app/components/deployments'
import useDocumentTitle from '@/hooks/use-document-title'
const DeploymentsPage = () => {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.list'))
return <DeploymentsMain />
}
export default React.memo(DeploymentsPage)

View File

@ -0,0 +1,296 @@
'use client'
import type { FC } from 'react'
import type { AppInfo } from './types'
import type { AppModeEnum } from '@/types/app'
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 { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useMemo, useRef, 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 { useRouter } from '@/next/navigation'
import { useDeploymentsStore } from './store'
import { useSourceApps } from './use-source-apps'
type AppPickerProps = {
apps: AppInfo[]
isLoading: boolean
value: string
onChange: (appId: string) => void
}
export const AppPicker: FC<AppPickerProps> = ({ apps, isLoading, value, onChange }) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [keywords, setKeywords] = useState('')
const triggerRef = useRef<HTMLButtonElement>(null)
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined)
const selected = useMemo(() => apps.find(a => a.id === value), [apps, value])
const filtered = useMemo(() => {
const q = keywords.trim().toLowerCase()
if (!q)
return apps
return apps.filter(a => a.name.toLowerCase().includes(q) || a.mode.toLowerCase().includes(q))
}, [apps, keywords])
const handleOpenChange = (next: boolean) => {
if (next && triggerRef.current)
setTriggerWidth(triggerRef.current.offsetWidth)
if (!next)
setKeywords('')
setOpen(next)
}
if (isLoading) {
return (
<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('createModal.loadingApps')}
</div>
)
}
if (apps.length === 0) {
return (
<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('createModal.noApps')}
</div>
)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<button
ref={triggerRef}
type="button"
className={cn(
'flex h-10 w-full items-center justify-between rounded-lg border-[0.5px] bg-components-input-bg-normal pr-2 pl-2 text-left transition-colors',
open
? 'border-components-input-border-active'
: 'border-components-input-border-active hover:border-components-input-border-hover',
)}
/>
)}
>
{selected
? (
<div className="flex min-w-0 items-center gap-2">
<div className="relative shrink-0">
<AppIcon
size="tiny"
iconType={selected.iconType}
icon={selected.icon}
background={selected.iconBackground}
imageUrl={selected.iconUrl}
/>
<AppTypeIcon
type={selected.mode as unknown as AppModeEnum}
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-3 h-3 shadow-sm"
className="h-2 w-2"
/>
</div>
<span className="truncate system-sm-medium text-text-secondary">{selected.name}</span>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{selected.mode}</span>
</div>
)
: (
<span className="system-sm-regular text-text-quaternary">
{t('createModal.appPickerPlaceholder')}
</span>
)}
<span
aria-hidden
className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary transition-transform', open && 'rotate-180')}
/>
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="p-0 overflow-hidden"
>
<div style={triggerWidth ? { width: triggerWidth } : undefined} className="flex flex-col">
<div className="p-2">
<Input
showLeftIcon
showClearIcon
placeholder={t('createModal.appSearchPlaceholder')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
autoFocus
/>
</div>
<div className="max-h-[280px] overflow-y-auto px-1 pb-1">
{filtered.length === 0
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
{t('createModal.appSearchEmpty')}
</div>
)
: filtered.map((app) => {
const isSelected = app.id === value
return (
<button
key={app.id}
type="button"
onClick={() => {
onChange(app.id)
setOpen(false)
setKeywords('')
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
'hover:bg-state-base-hover',
isSelected && 'bg-state-base-hover',
)}
>
<div className="relative shrink-0">
<AppIcon
size="tiny"
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-3 h-3 shadow-sm"
className="h-2 w-2"
/>
</div>
<span className="min-w-0 grow truncate system-sm-medium text-text-secondary">
{app.name}
</span>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">
{app.mode}
</span>
{isSelected && (
<span aria-hidden className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
)
}
const CreateInstanceForm: FC<{ onClose: () => void }> = ({ onClose }) => {
const { t } = useTranslation('deployments')
const router = useRouter()
const createInstance = useDeploymentsStore(state => state.createInstance)
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const { apps, isLoading } = useSourceApps()
const [appId, setAppId] = useState<string>('')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const selectedApp = apps.find(a => a.id === appId)
const canCreate = Boolean(appId && name.trim())
const handleCreate = (thenDeploy: boolean) => {
if (!canCreate)
return
const instanceId = createInstance({
appId,
name: name.trim(),
description: description.trim() || undefined,
})
if (thenDeploy) {
openDeployDrawer({ instanceId })
return
}
router.push(`/deployments/${instanceId}/overview`)
}
return (
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('createModal.title')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('createModal.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary">{t('createModal.sourceApp')}</label>
<AppPicker
apps={apps}
isLoading={isLoading}
value={appId}
onChange={setAppId}
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-name">
{t('createModal.nameLabel')}
</label>
<input
id="instance-name"
type="text"
value={name}
placeholder={selectedApp?.name ?? t('createModal.namePlaceholder')}
onChange={e => setName(e.target.value)}
className="flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 text-[13px] font-medium text-text-secondary outline-hidden placeholder:text-text-quaternary"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-desc">
{t('createModal.descriptionLabel')}
</label>
<textarea
id="instance-desc"
value={description}
placeholder={t('createModal.descriptionPlaceholder')}
onChange={e => setDescription(e.target.value)}
className="min-h-[80px] rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 py-2 text-[13px] text-text-secondary outline-hidden placeholder:text-text-quaternary"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{t('createModal.cancel')}
</Button>
<Button variant="secondary" disabled={!canCreate} onClick={() => handleCreate(false)}>
{t('createModal.create')}
</Button>
<Button variant="primary" disabled={!canCreate} onClick={() => handleCreate(true)}>
{t('createModal.createAndDeploy')}
</Button>
</div>
</div>
)
}
const CreateInstanceModal: FC = () => {
const modal = useDeploymentsStore(state => state.createInstanceModal)
const closeModal = useDeploymentsStore(state => state.closeCreateInstanceModal)
return (
<Dialog
open={modal.open}
onOpenChange={next => !next && closeModal()}
>
<DialogContent className="w-[520px] max-w-[90vw]">
<DialogCloseButton />
{modal.open && <CreateInstanceForm onClose={closeModal} />}
</DialogContent>
</Dialog>
)
}
export default CreateInstanceModal

View File

@ -0,0 +1,461 @@
'use client'
import type { FC } from 'react'
import type { CredentialBinding, Deployment, Environment, EnvVariable, Instance, Release } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { mockCredentials } from './mock-data'
import { HealthBadge, ModeBadge } from './status-badge'
import { useDeploymentsStore } from './store'
type RequiredBindings = {
model: string[]
plugin: string[]
envVars: { key: string, type: 'string' | 'secret' }[]
}
function deriveRequiredBindings(appId: string): RequiredBindings {
switch (appId) {
case 'app-payments-workflow':
return {
model: ['OpenAI', 'DeepSeek'],
plugin: ['Gmail', 'Notion'],
envVars: [
{ key: 'kn', type: 'string' },
{ key: 'dbkey', type: 'secret' },
],
}
case 'app-customer-support':
return {
model: ['OpenAI'],
plugin: ['Gmail'],
envVars: [
{ key: 'dbkey', type: 'secret' },
{ key: 'keyno', type: 'string' },
],
}
default:
return {
model: ['OpenAI'],
plugin: [],
envVars: [],
}
}
}
type 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: Environment }
const EnvironmentRow: FC<EnvironmentRowProps> = ({ env }) => (
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex items-center gap-2">
<span className="system-sm-semibold text-text-primary">{env.name}</span>
<ModeBadge mode={env.mode} />
<HealthBadge health={env.health} />
</div>
<span className="system-xs-regular text-text-tertiary uppercase">{env.backend}</span>
</div>
)
type DeployFormProps = {
instance: Instance
environments: Environment[]
releases: Release[]
deployments: Deployment[]
lockedEnvId?: string
presetReleaseId?: string
onCancel: () => void
onSubmit: (params: {
environmentId: string
releaseId?: string
releaseNote?: string
credentials: CredentialBinding[]
envVariables: EnvVariable[]
}) => void
}
const DeployForm: FC<DeployFormProps> = ({
instance,
environments,
releases,
deployments,
lockedEnvId,
presetReleaseId,
onCancel,
onSubmit,
}) => {
const { t } = useTranslation('deployments')
const bindingProfileId = instance.bindingProfileId ?? instance.appId
const required = useMemo(() => deriveRequiredBindings(bindingProfileId), [bindingProfileId])
const credentialsByProvider = useMemo(() => {
const map = new Map<string, typeof mockCredentials>()
mockCredentials.forEach((c) => {
const list = map.get(c.provider) ?? []
list.push(c)
map.set(c.provider, list)
})
return map
}, [])
const presetRelease = useMemo(
() => presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined,
[releases, presetReleaseId],
)
const isPromote = Boolean(presetRelease)
const existingDeployment = useMemo(
() => lockedEnvId
? deployments.find(d => d.instanceId === instance.id && d.environmentId === lockedEnvId)
: undefined,
[deployments, instance.id, lockedEnvId],
)
const [selectedEnvId, setSelectedEnvId] = useState<string>(
() => lockedEnvId ?? environments[0]?.id ?? '',
)
const [releaseNote, setReleaseNote] = useState<string>('')
const [modelCredentials, setModelCredentials] = useState<Record<string, string>>(() => {
const model: Record<string, string> = {}
required.model.forEach((provider) => {
const existing = existingDeployment?.credentials.find(c => c.kind === 'model' && c.provider === provider)
const first = credentialsByProvider.get(provider)?.[0]
model[provider] = existing?.credentialId ?? first?.id ?? ''
})
return model
})
const [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>(() => {
const plugin: Record<string, string> = {}
required.plugin.forEach((provider) => {
const existing = existingDeployment?.credentials.find(c => c.kind === 'plugin' && c.provider === provider)
const first = credentialsByProvider.get(provider)?.[0]
plugin[provider] = existing?.credentialId ?? first?.id ?? ''
})
return plugin
})
const [envValues, setEnvValues] = useState<Record<string, string>>(() => {
const env: Record<string, string> = {}
required.envVars.forEach((v) => {
const existing = existingDeployment?.envVariables.find(e => e.key === v.key)
env[v.key] = existing?.value ?? ''
})
return env
})
const canDeploy = Boolean(
selectedEnvId
&& required.model.every(p => modelCredentials[p])
&& required.plugin.every(p => pluginCredentials[p])
&& required.envVars.every(v => envValues[v.key]?.length),
)
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
const handleDeploy = () => {
if (!canDeploy)
return
const credentials: CredentialBinding[] = [
...required.model.map<CredentialBinding>(provider => ({
provider,
kind: 'model',
credentialId: modelCredentials[provider],
})),
...required.plugin.map<CredentialBinding>(provider => ({
provider,
kind: 'plugin',
credentialId: pluginCredentials[provider],
})),
]
const envVariables: EnvVariable[] = required.envVars.map<EnvVariable>(v => ({
key: v.key,
value: envValues[v.key] ?? '',
type: v.type,
}))
onSubmit({
environmentId: selectedEnvId,
releaseId: presetRelease?.id,
releaseNote: isPromote ? undefined : releaseNote,
credentials,
envVariables,
})
}
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">{presetRelease.id}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{presetRelease.gateCommitId}</span>
{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={selectedEnvId}
onChange={setSelectedEnvId}
options={environments.map(env => ({
value: env.id,
label: `${env.name} · ${t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${env.backend.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((provider) => {
const providerCreds = credentialsByProvider.get(provider) ?? []
return (
<LabeledSelect
key={provider}
label={provider}
value={modelCredentials[provider] ?? ''}
onChange={v => setModelCredentials(prev => ({ ...prev, [provider]: v }))}
options={providerCreds.map(c => ({
value: c.id,
label: `${c.name}${c.validated ? '' : t('deployDrawer.needsValidation')}`,
}))}
placeholder={t('deployDrawer.selectProviderKey', { provider })}
/>
)
})}
</div>
</Field>
)}
{required.plugin.length > 0 && (
<Field label={t('deployDrawer.pluginCreds')}>
<div className="flex flex-col gap-2">
{required.plugin.map((provider) => {
const providerCreds = credentialsByProvider.get(provider) ?? []
return (
<LabeledSelect
key={provider}
label={provider}
value={pluginCredentials[provider] ?? ''}
onChange={v => setPluginCredentials(prev => ({ ...prev, [provider]: v }))}
options={providerCreds.map(c => ({ value: c.id, label: c.name }))}
placeholder={t('deployDrawer.selectProviderCred', { provider })}
/>
)
})}
</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-16 shrink-0 system-xs-medium text-text-secondary">{v.key}</span>
<div className="flex h-8 min-w-0 flex-1 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
<input
type={v.type === 'secret' ? 'password' : 'text'}
value={envValues[v.key] ?? ''}
placeholder={v.type === 'secret' ? t('deployDrawer.secretPlaceholder') : t('deployDrawer.valuePlaceholder')}
onChange={e => setEnvValues(prev => ({ ...prev, [v.key]: e.target.value }))}
className={cn('min-w-0 flex-1 bg-transparent text-[13px] text-text-secondary outline-hidden placeholder:text-text-quaternary')}
/>
<span className="shrink-0 rounded-md border border-divider-subtle px-1.5 text-[10px] font-medium text-text-tertiary uppercase">
{v.type}
</span>
</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>
)
}
const DeployDrawer: FC = () => {
const { t } = useTranslation('deployments')
const drawer = useDeploymentsStore(state => state.deployDrawer)
const environments = useDeploymentsStore(state => state.environments)
const instances = useDeploymentsStore(state => state.instances)
const releases = useDeploymentsStore(state => state.releases)
const deployments = useDeploymentsStore(state => state.deployments)
const closeDeployDrawer = useDeploymentsStore(state => state.closeDeployDrawer)
const startDeploy = useDeploymentsStore(state => state.startDeploy)
const open = drawer.open
const instance = instances.find(i => i.id === drawer.instanceId)
const formKey = `${drawer.instanceId ?? 'none'}-${drawer.environmentId ?? 'any'}-${drawer.releaseId ?? 'new'}-${open ? '1' : '0'}`
return (
<Dialog
open={open}
onOpenChange={next => !next && closeDeployDrawer()}
>
<DialogContent className="w-[560px] max-w-[90vw]">
<DialogCloseButton />
{!instance
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (
<DeployForm
key={formKey}
instance={instance}
environments={environments}
releases={releases}
deployments={deployments}
lockedEnvId={drawer.environmentId}
presetReleaseId={drawer.releaseId}
onCancel={closeDeployDrawer}
onSubmit={({ environmentId, releaseId, releaseNote, credentials, envVariables }) =>
startDeploy({
instanceId: instance.id,
environmentId,
releaseId,
releaseNote,
credentials,
envVariables,
})}
/>
)}
</DialogContent>
</Dialog>
)
}
export default DeployDrawer

View File

@ -0,0 +1,523 @@
'use client'
import type { FC } from 'react'
import type { AppInfo, Deployment, DeployStatus, Environment, Instance } from './types'
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 './create-instance-modal'
import DeployDrawer from './deploy-drawer'
import RollbackModal from './rollback-modal'
import { useDeploymentsStore } from './store'
import { useSourceApps } from './use-source-apps'
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 = {
instance: Instance
app: AppInfo
deployments: Deployment[]
environments: Environment[]
}
const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, environments }) => {
const { t } = useTranslation('deployments')
const router = useRouter()
const { formatTimeFromNow } = useFormatTimeFromNow()
const [menuOpen, setMenuOpen] = useState(false)
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const deleteInstance = useDeploymentsStore(state => state.deleteInstance)
const navigateToDetail = () => router.push(`/deployments/${instance.id}/overview`)
const handleMenuAction = (e: React.MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
e.preventDefault()
setMenuOpen(false)
action()
}
const envCount = deployments.length
const failedCount = deployments.filter(d => d.status === 'deploy_failed').length
const deployingCount = deployments.filter(d => d.status === 'deploying').length
const readyCount = deployments.filter(d => d.status === 'ready').length
const envMap = useMemo(() => new Map(environments.map(env => [env.id, env])), [environments])
const lastDeployedAt = useMemo(() => {
if (deployments.length === 0)
return null
return deployments.reduce((latest, d) => {
const t = new Date(d.createdAt).getTime()
return t > latest ? t : latest
}, 0)
}, [deployments])
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: DeployStatus) => {
if (status === 'deploy_failed')
return t('status.deployFailed')
return t(`status.${status}`)
}
const statusTooltip = primaryStatus === 'none'
? t('card.tooltip.notDeployed')
: (
<div className="flex min-w-[220px] flex-col gap-1">
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
{deployments.map((deployment) => {
const env = envMap.get(deployment.environmentId)
return (
<div key={deployment.id} className="flex min-w-0 items-center justify-between gap-3">
<span className="min-w-0 truncate text-text-tertiary">
{env?.name ?? deployment.environmentId}
</span>
<span className="shrink-0 text-text-secondary">
{statusLabel(deployment.status)}
{' · '}
{deployment.activeReleaseId}
</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={instance.name}>{instance.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({ instanceId: instance.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
className="gap-2 px-3"
onClick={e => handleMenuAction(e, () => deleteInstance(instance.id))}
>
<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
}
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={() => {
onChange(option.value)
setOpen(false)
}}
className="flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none 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>
)
}
const DeploymentsMain: FC = () => {
const { t } = useTranslation('deployments')
const instances = useDeploymentsStore(state => state.instances)
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal)
const [envFilter, setEnvFilter] = useQueryState(
'env',
parseAsString.withDefault('all').withOptions({ history: 'push' }),
)
const [keywords, setKeywords] = useQueryState(
'keywords',
parseAsString.withDefault('').withOptions({ history: 'push' }),
)
const [keywordsInput, setKeywordsInput] = useState(keywords)
const { run: commitKeywords } = useDebounceFn((next: string) => {
void setKeywords(next.trim() ? next : null)
}, { wait: 300 })
const handleKeywordsChange = (next: string) => {
setKeywordsInput(next)
commitKeywords(next)
}
const { appMap } = useSourceApps()
const deploymentsByInstance = useMemo(() => {
const map = new Map<string, Deployment[]>()
deployments.forEach((d) => {
const list = map.get(d.instanceId) ?? []
list.push(d)
map.set(d.instanceId, list)
})
return map
}, [deployments])
const envIdSet = useMemo(() => new Set(environments.map(e => e.id)), [environments])
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed' || envIdSet.has(envFilter)
? envFilter
: 'all'
const filterOptions = useMemo(() => {
return [
{
value: 'all',
text: t('filter.allEnvs'),
icon: <span className="i-ri-apps-2-line h-[14px] w-[14px]" />,
},
...environments.map(env => ({
value: env.id,
text: env.name,
icon: <span className="i-ri-stack-line h-[14px] w-[14px]" />,
})),
{
value: 'not-deployed',
text: t('filter.notDeployed'),
icon: <span className="i-ri-inbox-line h-[14px] w-[14px]" />,
},
]
}, [environments, t])
const visibleInstances = useMemo(() => {
const byEnv = activeFilter === 'all'
? instances
: activeFilter === 'not-deployed'
? instances.filter(i => (deploymentsByInstance.get(i.id)?.length ?? 0) === 0)
: instances.filter(i => (deploymentsByInstance.get(i.id) ?? []).some(d => d.environmentId === activeFilter))
const q = keywords.trim().toLowerCase()
if (!q)
return byEnv
return byEnv.filter((i) => {
const app = appMap.get(i.appId)
return (
i.name.toLowerCase().includes(q)
|| (i.description ?? '').toLowerCase().includes(q)
|| (app?.name.toLowerCase().includes(q) ?? false)
)
})
}, [instances, deploymentsByInstance, activeFilter, keywords, appMap])
return (
<>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-end gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex items-center gap-2">
<EnvironmentFilter
value={activeFilter}
onChange={(next) => { void setEnvFilter(next) }}
options={filterOptions}
/>
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
placeholder={t('filter.searchPlaceholder')}
value={keywordsInput}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<NewInstanceCard onOpen={openCreateInstanceModal} />
{visibleInstances.map((instance) => {
const app = appMap.get(instance.appId)
if (!app)
return null
return (
<InstanceCard
key={instance.id}
instance={instance}
app={app}
deployments={deploymentsByInstance.get(instance.id) ?? []}
environments={environments}
/>
)
})}
</div>
<div className="py-4" />
</div>
<CreateInstanceModal />
<DeployDrawer />
<RollbackModal />
</>
)
}
export default DeploymentsMain

View File

@ -0,0 +1,571 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { AccessPermissionKind, EnvAccessPermission, Environment } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDeploymentsStore } from '../store'
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 = {
label: string
envName: string
value: string
onRevoke: () => void
}
const ApiKeyRow: FC<ApiKeyRowProps> = ({ label, envName, value, onRevoke }) => {
const { t } = useTranslation('deployments')
const [visible, setVisible] = useState(false)
const [copied, setCopied] = useState(false)
const displayValue = visible ? value : `${value.slice(0, 6)}${'•'.repeat(14)}${value.slice(-4)}`
const 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="flex items-center gap-3 py-1.5">
<div className="flex min-w-[140px] flex-col">
<span className="system-sm-medium text-text-primary">{label}</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.envPrefix', { env: envName })}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
<div className="min-w-0 flex-1 truncate font-mono text-[13px] font-medium text-text-secondary">
{displayValue}
</div>
<button
type="button"
onClick={() => setVisible(prev => !prev)}
aria-label={visible ? t('access.hide') : t('access.show')}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className={cn(visible ? 'i-ri-eye-off-line' : 'i-ri-eye-line', 'h-3.5 w-3.5')} />
</button>
<button
type="button"
onClick={handleCopy}
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',
external: 'i-ri-user-line',
anyone: 'i-ri-global-line',
}
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'external', '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 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: Environment[]
onGenerate: (environmentId: string) => void
}
const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGenerate }) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const disabled = environments.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]">
{environments.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: env.name })}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
function getUrlOrigin(url?: string) {
if (!url)
return undefined
try {
return new URL(url).origin
}
catch {
return url
}
}
type AccessTabProps = {
instanceId: string
}
const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
const { t } = useTranslation('deployments')
const instances = useDeploymentsStore(state => state.instances)
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const apiKeys = useDeploymentsStore(state => state.apiKeys)
const access = useDeploymentsStore(state => state.access)
const generateApiKey = useDeploymentsStore(state => state.generateApiKey)
const revokeApiKey = useDeploymentsStore(state => state.revokeApiKey)
const toggleAccessMethod = useDeploymentsStore(state => state.toggleAccessMethod)
const setEnvAccessPermission = useDeploymentsStore(state => state.setEnvAccessPermission)
const instance = instances.find(i => i.id === instanceId)
const instanceAccess = access.find(a => a.instanceId === instanceId)
const instanceDeployments = useMemo(
() => deployments.filter(d => d.instanceId === instanceId),
[deployments, instanceId],
)
const envMap = useMemo(
() => new Map(environments.map(env => [env.id, env])),
[environments],
)
const instanceKeys = useMemo(
() => apiKeys.filter(k => k.instanceId === instanceId),
[apiKeys, instanceId],
)
const deployedEnvs = useMemo(
() => instanceDeployments
.map(deployment => envMap.get(deployment.environmentId))
.filter((env): env is Environment => !!env),
[envMap, instanceDeployments],
)
const permissionByEnv = useMemo(() => {
const map = new Map<string, EnvAccessPermission>()
instanceAccess?.envPermissions.forEach((p) => {
map.set(p.environmentId, p)
})
return map
}, [instanceAccess])
if (!instance || !instanceAccess)
return null
const apiEnabled = instanceAccess.enabled.api
const runEnabled = instanceAccess.enabled.runAccess
const cliDomain = getUrlOrigin(instanceAccess.mcpUrl)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
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 current = permissionByEnv.get(env.id)
const kind = current?.kind ?? 'organization'
return (
<div
key={env.id}
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">
{env.name}
</span>
<PermissionPicker
value={kind}
onChange={next => setEnvAccessPermission(instanceId, env.id, next)}
/>
</div>
{kind === 'specific' && (
<div className="pl-0 system-xs-regular text-text-tertiary sm:pl-[152px]">
{t('access.permission.specificUnavailable')}
</div>
)}
</div>
)
})}
</div>
)}
</Section>
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
action={(
<Switch
checked={runEnabled}
onCheckedChange={v => toggleAccessMethod(instanceId, 'runAccess', v)}
/>
)}
>
{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>
{instanceAccess.webappUrl && deployedEnvs.length > 0
? (
<div className="flex flex-col gap-2">
{deployedEnvs.map(env => (
<EndpointRow
key={`webapp-${env.id}`}
envName={env.name}
label={t('access.runAccess.urlLabel')}
value={instanceAccess.webappUrl!}
openLabel={t('access.runAccess.openWebapp')}
/>
))}
</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 => toggleAccessMethod(instanceId, 'api', v)}
/>
)}
>
{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={environmentId => generateApiKey(instanceId, environmentId)}
/>
</div>
{instanceKeys.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">
{instanceKeys.map((k) => {
const env = envMap.get(k.environmentId)
return (
<ApiKeyRow
key={k.id}
label={k.label}
envName={env?.name ?? k.environmentId}
value={k.value}
onRevoke={() => revokeApiKey(k.id)}
/>
)
})}
</div>
)}
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.api.disabled')}
</div>
)}
</Section>
</div>
)
}
export default AccessTab

View File

@ -0,0 +1,454 @@
'use client'
import type { FC } from 'react'
import type { Deployment, Environment, Release } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { mockCredentials } from '../mock-data'
import { HealthBadge, ModeBadge } from '../status-badge'
import { useDeploymentsStore } from '../store'
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 = {
deployment: Deployment
env: Environment
release?: Release
targetRelease?: Release
failedRelease?: Release
}
const DeploymentPanel: FC<DeploymentPanelProps> = ({ deployment, env, release, targetRelease, failedRelease }) => {
const { t } = useTranslation('deployments')
const credentialMap = useMemo(
() => new Map(mockCredentials.map(c => [c.id, c])),
[],
)
const modelCreds = deployment.credentials.filter(c => c.kind === 'model')
const pluginCreds = deployment.credentials.filter(c => c.kind === 'plugin')
return (
<div className="border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<div className="mb-3 flex items-center gap-2">
<span className="system-sm-semibold text-text-primary">
{env.name}
{' · '}
{deployment.activeReleaseId}
</span>
<ModeBadge mode={env.mode} />
<HealthBadge health={env.health} />
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-4 md:grid-cols-2">
<InfoBlock title={t('deployTab.panel.instanceInfo')}>
<InfoRow label={t('deployTab.panel.deploymentId')} value={deployment.id} mono />
<InfoRow label={t('deployTab.panel.replicas')} value={deployment.replicas != null ? String(deployment.replicas) : '—'} />
<InfoRow label={t('deployTab.panel.runtimeMode')} value={t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${env.backend.toUpperCase()}`} />
<InfoRow label={t('deployTab.panel.runtimeNote')} value={deployment.runtimeNote ?? '—'} />
</InfoBlock>
<InfoBlock title={t('deployTab.panel.releaseInfo')}>
<InfoRow label={t('deployTab.panel.release')} value={release?.id ?? deployment.activeReleaseId} mono />
<InfoRow label={t('deployTab.panel.commit')} value={release?.gateCommitId ?? '—'} mono />
<InfoRow label={t('deployTab.panel.createdAt')} value={release?.createdAt ?? '—'} />
{targetRelease && (
<InfoRow label={t('deployTab.panel.targetRelease')} value={`${targetRelease.id} / ${targetRelease.gateCommitId}`} mono />
)}
{failedRelease && (
<InfoRow label={t('deployTab.panel.failedRelease')} value={`${failedRelease.id} / ${failedRelease.gateCommitId}`} mono />
)}
</InfoBlock>
<InfoBlock title={t('deployTab.panel.endpoints')}>
<InfoRow label={t('deployTab.panel.run')} value={`/${env.namespace}/run`} mono />
<InfoRow label={t('deployTab.panel.health')} value={`/${env.namespace}/readyz`} mono />
</InfoBlock>
{modelCreds.length > 0 && (
<InfoBlock title={t('deployTab.panel.modelCreds')}>
{modelCreds.map(c => (
<InfoRow
key={`model-${c.provider}`}
label={c.provider}
value={credentialMap.get(c.credentialId ?? '')?.name ?? '—'}
mono
/>
))}
</InfoBlock>
)}
{pluginCreds.length > 0 && (
<InfoBlock title={t('deployTab.panel.pluginCreds')}>
{pluginCreds.map(c => (
<InfoRow
key={`plugin-${c.provider}`}
label={c.provider}
value={credentialMap.get(c.credentialId ?? '')?.name ?? '—'}
mono
/>
))}
</InfoBlock>
)}
{deployment.envVariables.length > 0 && (
<InfoBlock title={t('deployTab.panel.envVars')}>
{deployment.envVariables.map(v => (
<InfoRow
key={v.key}
label={v.key}
value={v.type === 'secret' ? '••••••' : v.value}
mono
suffix={` (${v.type})`}
/>
))}
</InfoBlock>
)}
</div>
{deployment.status === 'deploy_failed' && deployment.errorMessage && (
<div className="mt-4 rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
{deployment.errorMessage}
</div>
)}
</div>
)
}
type DeploymentStatusSummaryProps = {
deployment: Deployment
}
const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ deployment }) => {
const { t } = useTranslation('deployments')
if (deployment.status === 'deploying') {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
{t('deployTab.status.deployingRelease', { release: deployment.targetReleaseId ?? deployment.activeReleaseId })}
</span>
)
}
if (deployment.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 RowPrimaryActionProps = {
deployment: Deployment
onPromote: () => void
onViewProgress: () => void
onViewLogs: () => void
}
const RowPrimaryAction: FC<RowPrimaryActionProps> = ({ deployment, onPromote, onViewProgress, onViewLogs }) => {
const { t } = useTranslation('deployments')
if (deployment.status === 'deploying') {
return (
<Button size="small" variant="secondary" onClick={onViewProgress}>
{t('deployTab.viewProgress')}
</Button>
)
}
if (deployment.status === 'deploy_failed') {
return (
<Button size="small" variant="secondary" onClick={onViewLogs}>
{t('deployTab.viewLogs')}
</Button>
)
}
return (
<Button size="small" variant="secondary" onClick={onPromote}>
{t('deployTab.deployOtherVersion')}
</Button>
)
}
type DeploymentMenuProps = {
deployment: Deployment
onUndeploy: () => void
}
const DeploymentMenu: FC<DeploymentMenuProps> = ({ deployment, onUndeploy }) => {
const { t } = useTranslation('deployments')
const [menuOpen, setMenuOpen] = useState(false)
const itemLabel = deployment.status === 'deploying'
? t('deployTab.cancelDeployment')
: t('deployTab.undeploy')
return (
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className="flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-more-line h-4 w-4" />
</DropdownMenuTrigger>
{menuOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setMenuOpen(false)
onUndeploy()
}}
>
<span className="system-sm-regular text-text-destructive">
{itemLabel}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
type DeployTabProps = {
instanceId: string
}
const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
const { t } = useTranslation('deployments')
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const undeployDeployment = useDeploymentsStore(state => state.undeployDeployment)
const releases = useDeploymentsStore(state => state.releases)
const instanceDeployments = useMemo(
() => deployments.filter(d => d.instanceId === instanceId),
[deployments, instanceId],
)
const envMap = useMemo(
() => new Map(environments.map(env => [env.id, env])),
[environments],
)
const [expanded, setExpanded] = useState<string | null>(() => instanceDeployments[0]?.id ?? null)
const toggle = (id: string) => setExpanded(prev => (prev === id ? null : id))
const [deployMenuOpen, setDeployMenuOpen] = useState(false)
const availableEnvs = environments.filter(env => !instanceDeployments.some(d => d.environmentId === env.id))
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<div className="system-sm-semibold text-text-primary">
{t('deployTab.envCount')}
{' '}
<span className="system-sm-regular text-text-tertiary">
(
{instanceDeployments.length}
)
</span>
</div>
<DropdownMenu modal={false} open={deployMenuOpen} onOpenChange={setDeployMenuOpen}>
<DropdownMenuTrigger
className={cn(
'inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-3 system-sm-medium',
'border border-components-button-primary-border bg-components-button-primary-bg text-components-button-primary-text',
'hover:bg-components-button-primary-bg-hover',
)}
>
<span className="i-ri-rocket-line h-3.5 w-3.5" />
{t('deployTab.newDeployment')}
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
{deployMenuOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setDeployMenuOpen(false)
openDeployDrawer({ instanceId })
}}
>
<span className="system-sm-regular text-text-secondary">{t('deployTab.deployToNewEnv')}</span>
</DropdownMenuItem>
{availableEnvs.length > 0 && (
<>
<div className="px-3 py-1 system-xs-medium-uppercase text-text-quaternary">{t('deployTab.shortcut')}</div>
{availableEnvs.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
onClick={() => {
setDeployMenuOpen(false)
openDeployDrawer({ instanceId, environmentId: env.id })
}}
>
<span className="system-sm-regular text-text-secondary">
{t('deployTab.deployToEnv', { name: env.name })}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
</div>
{instanceDeployments.length === 0
? (
<div className="rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{t('deployTab.empty')}
</div>
)
: (
<div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg">
<div className={cn(
'hidden items-center gap-4 border-b border-divider-subtle px-4 py-3 system-xs-medium-uppercase text-text-tertiary lg:grid',
GRID_TEMPLATE,
)}
>
<div>{t('deployTab.col.environment')}</div>
<div>{t('deployTab.col.currentRelease')}</div>
<div>{t('deployTab.col.status')}</div>
<div />
</div>
{instanceDeployments.map((deployment) => {
const env = envMap.get(deployment.environmentId)
if (!env)
return null
const isExpanded = expanded === deployment.id
const release = releases.find(r => r.id === deployment.activeReleaseId)
const targetRelease = deployment.targetReleaseId ? releases.find(r => r.id === deployment.targetReleaseId) : undefined
const failedRelease = deployment.failedReleaseId ? releases.find(r => r.id === deployment.failedReleaseId) : undefined
const actions = (
<div className="flex shrink-0 items-center gap-1" onClick={e => e.stopPropagation()}>
<RowPrimaryAction
deployment={deployment}
onPromote={() => openDeployDrawer({ instanceId, environmentId: deployment.environmentId })}
onViewProgress={() => setExpanded(deployment.id)}
onViewLogs={() => setExpanded(deployment.id)}
/>
<DeploymentMenu
deployment={deployment}
onUndeploy={() => undeployDeployment(deployment.id)}
/>
</div>
)
const chevron = (
<span
className={cn(
'i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary transition-transform',
isExpanded && 'rotate-180',
)}
/>
)
return (
<div key={deployment.id} className="border-b border-divider-subtle last:border-b-0">
<button
type="button"
onClick={() => toggle(deployment.id)}
className={cn(
'flex w-full flex-col gap-2 px-4 py-3 text-left hover:bg-state-base-hover',
'lg:grid lg:items-center lg:gap-4',
GRID_TEMPLATE,
)}
>
<div className="flex items-start justify-between gap-3 lg:block">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate system-sm-semibold text-text-primary">{env.name}</span>
<div className="flex items-center gap-1.5 system-xs-regular text-text-tertiary">
<span className="uppercase">{env.backend}</span>
<span>·</span>
<span>{t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 lg:hidden">
{actions}
{chevron}
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 lg:contents">
<div className="flex items-center gap-2">
<span className="font-mono system-sm-medium text-text-primary">{deployment.activeReleaseId}</span>
{release && (
<span className="font-mono system-xs-regular text-text-tertiary">{release.gateCommitId}</span>
)}
</div>
<div>
<DeploymentStatusSummary deployment={deployment} />
</div>
</div>
<div className="hidden items-center justify-end gap-1 lg:flex">
{actions}
{chevron}
</div>
</button>
{isExpanded && (
<DeploymentPanel
deployment={deployment}
env={env}
release={release}
targetRelease={targetRelease}
failedRelease={failedRelease}
/>
)}
</div>
)
})}
</div>
)}
</div>
)
}
export default DeployTab

View File

@ -0,0 +1,314 @@
'use client'
import type { ComponentProps, FC, PropsWithoutRef, ReactNode } from 'react'
import type { AppInfo } from '../types'
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 { 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 '../deploy-drawer'
import RollbackModal from '../rollback-modal'
import { useDeploymentsStore } from '../store'
import { useSourceApps } from '../use-source-apps'
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()
const router = useRouter()
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
const instances = useDeploymentsStore(state => state.instances)
const deployments = useDeploymentsStore(state => state.deployments)
const { appMap, isLoading: isLoadingApps } = useSourceApps()
useDocumentTitle(t('documentTitle.detail'))
const instance = useMemo(() => instances.find(i => i.id === instanceId), [instances, instanceId])
const app = useMemo(
() => instance ? appMap.get(instance.appId) : undefined,
[instance, appMap],
)
const instanceDeployments = useMemo(
() => instance ? deployments.filter(d => d.instanceId === instance.id) : [],
[deployments, instance],
)
if (isLoadingApps && !app) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
</div>
)
}
if (!instance) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-body">
<div className="title-xl-semi-bold text-text-primary">{t('detail.notFound')}</div>
<Button variant="secondary" onClick={() => router.push('/deployments')}>
<span aria-hidden className="i-ri-arrow-left-line h-4 w-4" />
{t('detail.backToInstances')}
</Button>
</div>
)
}
const deployingCount = instanceDeployments.filter(d => d.status === 'deploying').length
const failedCount = instanceDeployments.filter(d => d.status === 'deploy_failed').length
const appModeLabel = app ? getAppModeLabel(app.mode, tCommon) : t('detail.sourceAppDeleted')
return (
<>
<div className="relative flex h-full overflow-hidden rounded-t-2xl shadow-[0_0_5px_rgba(0,0,0,0.05),0_0_2px_-1px_rgba(0,0,0,0.03)]">
<DeploymentSidebar
instanceId={instanceId}
instanceName={instance.name}
instanceDescription={instance.description}
appModeLabel={appModeLabel}
app={app}
/>
<div className="grow overflow-hidden bg-components-panel-bg">
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b border-solid border-b-divider-regular px-6 py-3">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<div className="title-md-semi-bold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
</div>
<div className="system-xs-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
<div className="flex items-center gap-2 system-xs-regular text-text-tertiary">
<span>{t('detail.envCount', { count: instanceDeployments.length })}</span>
{deployingCount > 0 && (
<>
<span>·</span>
<span className="text-util-colors-warning-warning-700">
{t('detail.deployingCount', { count: deployingCount })}
</span>
</>
)}
{failedCount > 0 && (
<>
<span>·</span>
<span className="text-util-colors-red-red-700">
{t('detail.failedCount', { count: failedCount })}
</span>
</>
)}
</div>
</div>
<div className="grow overflow-y-auto">
{children}
</div>
</div>
</div>
</div>
<DeployDrawer />
<RollbackModal />
</>
)
}
export default InstanceDetail

View File

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

View File

@ -0,0 +1,133 @@
'use client'
import type { FC } from 'react'
import type { Instance } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { useDeploymentsStore } from '../store'
type SettingsTabProps = {
instanceId: string
}
type SettingsFormProps = {
instance: Instance
hasDeployments: boolean
}
const SettingsForm: FC<SettingsFormProps> = ({ instance, hasDeployments }) => {
const { t } = useTranslation('deployments')
const router = useRouter()
const updateInstance = useDeploymentsStore(state => state.updateInstance)
const deleteInstance = useDeploymentsStore(state => state.deleteInstance)
const [name, setName] = useState(instance.name)
const [description, setDescription] = useState(instance.description ?? '')
const dirty = name !== instance.name || description !== (instance.description ?? '')
const handleSave = () => {
if (!name.trim())
return
updateInstance(instance.id, {
name: name.trim(),
description: description.trim() || undefined,
})
toast.success(t('settings.updated'))
}
const handleReset = () => {
setName(instance.name)
setDescription(instance.description ?? '')
}
const handleDelete = () => {
if (hasDeployments) {
toast.error(t('settings.undeployFirst'))
return
}
deleteInstance(instance.id)
router.push('/deployments')
}
return (
<div className="flex max-w-[640px] flex-col gap-5 p-6">
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
<div className="system-sm-semibold text-text-primary">{t('settings.general')}</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-name">
{t('settings.name')}
</label>
<input
id="settings-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 text-[13px] font-medium text-text-secondary outline-hidden placeholder:text-text-quaternary"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-desc">
{t('settings.description')}
</label>
<textarea
id="settings-desc"
value={description}
onChange={e => setDescription(e.target.value)}
className="min-h-[96px] rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 py-2 text-[13px] text-text-secondary outline-hidden placeholder:text-text-quaternary"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" disabled={!dirty} onClick={handleReset}>
{t('settings.reset')}
</Button>
<Button variant="primary" disabled={!dirty || !name.trim()} onClick={handleSave}>
{t('settings.save')}
</Button>
</div>
</div>
<div className="flex flex-col gap-3 rounded-xl border border-util-colors-red-red-200 bg-util-colors-red-red-50 p-4">
<div className="system-sm-semibold text-util-colors-red-red-700">{t('settings.danger')}</div>
<div className="system-xs-regular text-util-colors-red-red-600">
{t('settings.dangerDesc')}
</div>
<div className="flex items-center justify-between gap-2">
<div className="system-xs-regular text-text-tertiary">
{hasDeployments
? t('settings.undeployFirst')
: t('settings.safeToDelete')}
</div>
<Button
variant="primary"
tone="destructive"
disabled={hasDeployments}
onClick={handleDelete}
>
{t('settings.delete')}
</Button>
</div>
</div>
</div>
)
}
const SettingsTab: FC<SettingsTabProps> = ({ instanceId }) => {
const instances = useDeploymentsStore(state => state.instances)
const deployments = useDeploymentsStore(state => state.deployments)
const instance = instances.find(i => i.id === instanceId)
if (!instance)
return null
const hasDeployments = deployments.some(d => d.instanceId === instanceId)
const formKey = `${instance.id}-${instance.name}-${instance.description ?? ''}`
return <SettingsForm key={formKey} instance={instance} hasDeployments={hasDeployments} />
}
export default SettingsTab

View File

@ -0,0 +1,9 @@
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'versions', 'access', 'settings'] as const
export type InstanceDetailTabKey = typeof INSTANCE_DETAIL_TAB_KEYS[number]
const INSTANCE_DETAIL_TAB_KEY_SET = new Set<string>(INSTANCE_DETAIL_TAB_KEYS)
export function isInstanceDetailTabKey(value?: string): value is InstanceDetailTabKey {
return value != null && INSTANCE_DETAIL_TAB_KEY_SET.has(value)
}

View File

@ -0,0 +1,386 @@
'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 { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDeploymentsStore } from '../store'
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',
}
type DeployReleaseMenuProps = {
releaseId: string
instanceId: string
}
const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ releaseId, instanceId }) => {
const { t } = useTranslation('deployments')
const environments = useDeploymentsStore(state => state.environments)
const deployments = useDeploymentsStore(state => state.deployments)
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const openRollbackModal = useDeploymentsStore(state => state.openRollbackModal)
const [open, setOpen] = useState(false)
const instanceDeployments = deployments.filter(d => d.instanceId === instanceId)
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 deployment = instanceDeployments.find(d => d.environmentId === env.id)
const isCurrent = deployment?.activeReleaseId === releaseId
const isEnvironmentDeploying = deployment?.status === 'deploying'
return (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
disabled={isCurrent || isEnvironmentDeploying}
onClick={() => {
setOpen(false)
if (isCurrent || isEnvironmentDeploying)
return
if (deployment) {
openRollbackModal({
deploymentId: deployment.id,
targetReleaseId: releaseId,
})
return
}
openDeployDrawer({ instanceId, environmentId: env.id, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{isEnvironmentDeploying
? t('versions.deployingTo', { name: env.name })
: isCurrent
? t('versions.currentOn', { name: env.name })
: deployment
? t('versions.promoteTo', { name: env.name })
: t('versions.deployTo', { name: env.name })}
</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
type ReleaseMoreMenuProps = {
previewVisible: boolean
onTogglePreview: () => void
}
const ReleaseMoreMenu: FC<ReleaseMoreMenuProps> = ({ previewVisible, onTogglePreview }) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('versions.moreActions')}
className={cn(
open ? 'bg-state-base-hover text-text-secondary' : 'text-text-tertiary',
'flex h-7 w-7 items-center justify-center rounded-md hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span className="i-ri-more-line h-4 w-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[180px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setOpen(false)
onTogglePreview()
}}
>
<span className="i-ri-file-code-line h-4 w-4 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{previewVisible ? t('versions.hideYaml') : t('versions.viewYaml')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
const DeployedToBadge: FC<{ item: ReleaseDeployment }> = ({ item }) => {
const { t } = useTranslation('deployments')
const statusLabel = t(`versions.deployedStatus.${item.state}`)
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
}
const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
const { t } = useTranslation('deployments')
const instances = useDeploymentsStore(state => state.instances)
const releases = useDeploymentsStore(state => state.releases)
const deployments = useDeploymentsStore(state => state.deployments)
const environments = useDeploymentsStore(state => state.environments)
const instance = instances.find(i => i.id === instanceId)
const instanceDeployments = useMemo(
() => deployments.filter(d => d.instanceId === instanceId),
[deployments, instanceId],
)
const appReleases = useMemo(() => {
if (!instance)
return []
const deployedReleaseIds = new Set<string>()
instanceDeployments.forEach((deployment) => {
deployedReleaseIds.add(deployment.activeReleaseId)
if (deployment.targetReleaseId)
deployedReleaseIds.add(deployment.targetReleaseId)
if (deployment.failedReleaseId)
deployedReleaseIds.add(deployment.failedReleaseId)
})
return releases.filter(r => r.appId === instance.appId || deployedReleaseIds.has(r.id))
}, [releases, instance, instanceDeployments])
const [previewId, setPreviewId] = useState<string | null>(null)
if (!instance)
return null
const envMap = new Map(environments.map(env => [env.id, env]))
const getReleaseDeployments = (releaseId: string) => {
return instanceDeployments.flatMap((deployment) => {
const env = envMap.get(deployment.environmentId)
if (!env)
return []
const items: ReleaseDeployment[] = []
if (deployment.activeReleaseId === releaseId) {
items.push({
environmentId: deployment.environmentId,
environmentName: env.name,
state: 'active',
})
}
if (deployment.status === 'deploying' && deployment.targetReleaseId === releaseId) {
items.push({
environmentId: deployment.environmentId,
environmentName: env.name,
state: 'deploying',
})
}
if (deployment.status === 'deploy_failed' && deployment.failedReleaseId === releaseId) {
items.push({
environmentId: deployment.environmentId,
environmentName: env.name,
state: 'failed',
})
}
return items
})
}
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<div className="system-sm-semibold text-text-primary">
{t('versions.releaseHistory')}
{' '}
<span className="system-sm-regular text-text-tertiary">
(
{appReleases.length}
)
</span>
</div>
</div>
{appReleases.length === 0
? (
<div className="rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{t('versions.empty')}
</div>
)
: (
<div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg">
<div className={cn(
'hidden items-center gap-4 border-b border-divider-subtle px-4 py-3 system-xs-medium-uppercase text-text-tertiary lg:grid',
GRID_TEMPLATE,
)}
>
<div>{t('versions.col.release')}</div>
<div>{t('versions.col.createdAt')}</div>
<div>{t('versions.col.author')}</div>
<div>{t('versions.col.deployedTo')}</div>
<div className="text-right">{t('versions.col.action')}</div>
</div>
{appReleases.map((release) => {
const releaseDeployments = getReleaseDeployments(release.id)
const isPreview = previewId === release.id
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">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t('versions.col.release')}
</div>
<Tooltip>
<TooltipTrigger
render={(
<span className="mt-1 inline-flex max-w-full cursor-default truncate font-mono system-sm-medium text-text-primary">
{release.id}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: release.gateCommitId })}
</TooltipContent>
</Tooltip>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-tertiary">
<span>{release.createdAt}</span>
<span aria-hidden>·</span>
<span>{release.operator}</span>
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu releaseId={release.id} instanceId={instanceId} />
<ReleaseMoreMenu
previewVisible={isPreview}
onTogglePreview={() => setPreviewId(prev => (prev === release.id ? null : release.id))}
/>
</div>
</div>
<div>
<div className="system-xs-medium-uppercase text-text-tertiary">
{t('versions.col.deployedTo')}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{releaseDeployments.length === 0
? <span className="system-sm-regular text-text-quaternary"></span>
: releaseDeployments.map(item => (
<DeployedToBadge
key={`${item.environmentId}-${item.state}`}
item={item}
/>
))}
</div>
</div>
</div>
<div className={cn(
'hidden items-center gap-4 px-4 py-3 lg:grid',
GRID_TEMPLATE,
)}
>
<div>
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex cursor-default font-mono system-sm-medium text-text-primary">
{release.id}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: release.gateCommitId })}
</TooltipContent>
</Tooltip>
</div>
<div className="system-sm-regular text-text-secondary">{release.createdAt}</div>
<div className="system-sm-regular text-text-secondary">{release.operator}</div>
<div className="flex flex-wrap gap-1">
{releaseDeployments.length === 0
? <span className="system-sm-regular text-text-quaternary"></span>
: releaseDeployments.map(item => (
<DeployedToBadge
key={`${item.environmentId}-${item.state}`}
item={item}
/>
))}
</div>
<div className="flex justify-end gap-1">
<DeployReleaseMenu releaseId={release.id} instanceId={instanceId} />
<ReleaseMoreMenu
previewVisible={isPreview}
onTogglePreview={() => setPreviewId(prev => (prev === release.id ? null : release.id))}
/>
</div>
</div>
{isPreview && (
<div className="border-t border-divider-subtle bg-background-default-subtle">
<pre className="overflow-auto px-4 py-3 font-mono text-[12.5px] leading-5 text-text-secondary">
{release.yaml}
</pre>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
export default VersionsTab

View File

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

View File

@ -0,0 +1,100 @@
'use client'
import type { FC } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useDeploymentsStore } from './store'
import { useSourceApps } from './use-source-apps'
const InfoRow: FC<{ label: string, value: string }> = ({ label, value }) => {
return (
<div className="flex items-start justify-between gap-4">
<span className="system-xs-medium-uppercase text-text-tertiary">{label}</span>
<span className="system-sm-medium text-text-primary">{value}</span>
</div>
)
}
const RollbackModal: FC = () => {
const { t } = useTranslation('deployments')
const modal = useDeploymentsStore(state => state.rollbackModal)
const deployments = useDeploymentsStore(state => state.deployments)
const instances = useDeploymentsStore(state => state.instances)
const releases = useDeploymentsStore(state => state.releases)
const environments = useDeploymentsStore(state => state.environments)
const closeRollbackModal = useDeploymentsStore(state => state.closeRollbackModal)
const rollbackDeployment = useDeploymentsStore(state => state.rollbackDeployment)
const { appMap } = useSourceApps()
const deployment = deployments.find(d => d.id === modal.deploymentId)
const targetRelease = releases.find(r => r.id === modal.targetReleaseId)
const currentRelease = releases.find(r => r.id === deployment?.activeReleaseId)
const environment = environments.find(env => env.id === deployment?.environmentId)
const instance = instances.find(i => i.id === deployment?.instanceId)
const app = instance ? appMap.get(instance.appId) : undefined
const confirm = () => {
if (!modal.deploymentId || !modal.targetReleaseId)
return
rollbackDeployment(modal.deploymentId, modal.targetReleaseId)
}
return (
<AlertDialog
open={modal.open}
onOpenChange={next => !next && closeRollbackModal()}
>
<AlertDialogContent className="w-[520px]">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('rollback.title', { release: targetRelease?.id ?? '-' })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t('rollback.description')}
</AlertDialogDescription>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
<InfoRow label={t('rollback.instance')} value={instance?.name ?? '-'} />
<InfoRow label={t('rollback.sourceApp')} value={app?.name ?? '-'} />
<InfoRow label={t('rollback.environment')} value={environment?.name ?? '-'} />
<InfoRow
label={t('rollback.currentRelease')}
value={currentRelease ? `${currentRelease.id} / ${currentRelease.gateCommitId}` : '-'}
/>
<InfoRow
label={t('rollback.rollbackTo')}
value={targetRelease ? `${targetRelease.id} / ${targetRelease.gateCommitId}` : '-'}
/>
</div>
<div className="rounded-lg border border-dashed border-util-colors-red-red-200 bg-util-colors-red-red-50 p-3">
<div className="title-sm-semi-bold text-util-colors-red-red-700">
{t('rollback.impactingTitle')}
</div>
<p className="mt-1 system-xs-regular text-util-colors-red-red-600">
{t('rollback.impactingBody')}
</p>
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">
{t('rollback.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={confirm}>
{t('rollback.confirm')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default RollbackModal

View File

@ -0,0 +1,71 @@
'use client'
import type { FC } from 'react'
import type { DeployStatus, EnvironmentHealth, EnvironmentMode } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type StatusBadgeProps = {
status: DeployStatus
className?: string
}
const statusStyles: Record<DeployStatus, string> = {
ready: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
deploy_failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
}
const statusKey = {
ready: 'status.ready',
deploying: 'status.deploying',
deploy_failed: 'status.deployFailed',
} as const satisfies Record<DeployStatus, string>
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
export const StatusBadge: FC<StatusBadgeProps> = ({ status, className }) => {
const { t } = useTranslation('deployments')
return (
<span className={cn(baseBadge, statusStyles[status], className)}>
{status === 'deploying' && (
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{t(statusKey[status])}
</span>
)
}
type ModeBadgeProps = {
mode: EnvironmentMode
className?: string
}
export const ModeBadge: FC<ModeBadgeProps> = ({ mode, className }) => {
const { t } = useTranslation('deployments')
const style = mode === 'shared'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(mode === 'shared' ? 'mode.shared' : 'mode.isolated')}
</span>
)
}
type HealthBadgeProps = {
health: EnvironmentHealth
className?: string
}
export const HealthBadge: FC<HealthBadgeProps> = ({ health, className }) => {
const { t } = useTranslation('deployments')
const style = health === 'ready'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(health === 'ready' ? 'health.ready' : 'health.degraded')}
</span>
)
}

View File

@ -0,0 +1,431 @@
import type { AccessMethod, AccessPermissionKind, ApiKey, AppInfo, CredentialBinding, Deployment, Environment, EnvVariable, Instance, InstanceAccess, Release } from './types'
import { create } from 'zustand'
import { MOCK_APP_ID_SLOTS, mockAccess, mockApiKeys, mockDeployments, mockEnvironments, mockInstances, mockReleases } from './mock-data'
const DEPLOY_MOCK_DURATION_MS = 2000
let releaseCounter = 44
let apiKeyCounter = 100
let instanceCounter = 100
function generateReleaseId() {
const id = `R-0${releaseCounter}`
releaseCounter += 1
return id
}
function generateApiKeyId() {
const id = `apikey-${apiKeyCounter}`
apiKeyCounter += 1
return id
}
function generateInstanceId() {
const id = `instance-new-${instanceCounter}`
instanceCounter += 1
return id
}
function randomGateCommitId() {
return Math.random().toString(16).slice(2, 10)
}
function nowStamp() {
return new Date().toISOString().replace('T', ' ').slice(0, 16)
}
export type StartDeployParams = {
instanceId: string
environmentId: string
releaseId?: string
releaseNote?: string
credentials: CredentialBinding[]
envVariables: EnvVariable[]
}
type OpenDeployDrawerParams = {
instanceId: string
environmentId?: string
releaseId?: string
}
type OpenRollbackParams = {
deploymentId: string
targetReleaseId: string
}
export type CreateInstanceParams = {
appId: string
name: string
description?: string
}
type DeploymentsState = {
environments: Environment[]
instances: Instance[]
deployments: Deployment[]
releases: Release[]
apiKeys: ApiKey[]
access: InstanceAccess[]
seededAppIds: string[] | null
deployDrawer: {
open: boolean
instanceId?: string
environmentId?: string
releaseId?: string
}
rollbackModal: {
open: boolean
deploymentId?: string
targetReleaseId?: string
}
createInstanceModal: { open: boolean }
openDeployDrawer: (params: OpenDeployDrawerParams) => void
closeDeployDrawer: () => void
openRollbackModal: (params: OpenRollbackParams) => void
closeRollbackModal: () => void
openCreateInstanceModal: () => void
closeCreateInstanceModal: () => void
seedInstancesFromApps: (apps: AppInfo[]) => void
createInstance: (params: CreateInstanceParams) => string
updateInstance: (instanceId: string, patch: Partial<Pick<Instance, 'name' | 'description'>>) => void
switchSourceApp: (instanceId: string, appId: string) => void
deleteInstance: (instanceId: string) => void
startDeploy: (params: StartDeployParams) => void
retryDeploy: (deploymentId: string) => void
rollbackDeployment: (deploymentId: string, targetReleaseId: string) => void
undeployDeployment: (deploymentId: string) => void
generateApiKey: (instanceId: string, environmentId: string) => void
revokeApiKey: (apiKeyId: string) => void
toggleAccessMethod: (instanceId: string, method: AccessMethod, enabled: boolean) => void
setEnvAccessPermission: (instanceId: string, environmentId: string, kind: AccessPermissionKind) => void
setEnvAccessMembers: (
instanceId: string,
environmentId: string,
members: { memberIds: string[], groupIds: string[] },
) => void
}
function updateDeployment(deployments: Deployment[], deploymentId: string, patch: Partial<Deployment>): Deployment[] {
return deployments.map(item => item.id === deploymentId ? { ...item, ...patch } : item)
}
export const useDeploymentsStore = create<DeploymentsState>((set, get) => ({
environments: mockEnvironments,
instances: mockInstances,
deployments: mockDeployments,
releases: mockReleases,
apiKeys: mockApiKeys,
access: mockAccess,
seededAppIds: null,
deployDrawer: { open: false },
rollbackModal: { open: false },
createInstanceModal: { open: false },
openDeployDrawer: params => set({
deployDrawer: {
open: true,
instanceId: params.instanceId,
environmentId: params.environmentId,
releaseId: params.releaseId,
},
}),
closeDeployDrawer: () => set({ deployDrawer: { open: false } }),
openRollbackModal: ({ deploymentId, targetReleaseId }) => set({
rollbackModal: { open: true, deploymentId, targetReleaseId },
}),
closeRollbackModal: () => set({ rollbackModal: { open: false } }),
openCreateInstanceModal: () => set({ createInstanceModal: { open: true } }),
closeCreateInstanceModal: () => set({ createInstanceModal: { open: false } }),
seedInstancesFromApps: (apps) => {
if (apps.length === 0)
return
const realIds = apps.map(a => a.id)
const previous = get().seededAppIds
const unchanged
= previous !== null
&& previous.length === realIds.length
&& previous.every((id, i) => id === realIds[i])
if (unchanged)
return
const slotMap: Record<string, string> = {}
MOCK_APP_ID_SLOTS.forEach((mockId, idx) => {
const real = apps[idx % apps.length]!
slotMap[mockId] = real.id
})
set(state => ({
instances: state.instances.map((i) => {
const bindingProfileId = i.bindingProfileId ?? i.appId
return {
...i,
appId: slotMap[bindingProfileId] ?? i.appId,
bindingProfileId,
}
}),
releases: state.releases.map(r => ({
...r,
appId: slotMap[r.appId] ?? r.appId,
})),
seededAppIds: realIds,
}))
},
createInstance: ({ appId, name, description }) => {
const id = generateInstanceId()
const instance: Instance = {
id,
appId,
name,
description,
createdAt: nowStamp(),
}
set(state => ({
instances: [...state.instances, instance],
access: [
...state.access,
{
instanceId: id,
enabled: { api: true, runAccess: true },
envPermissions: [],
},
],
createInstanceModal: { open: false },
}))
return id
},
updateInstance: (instanceId, patch) => {
set(state => ({
instances: state.instances.map(item => item.id === instanceId ? { ...item, ...patch } : item),
}))
},
switchSourceApp: (instanceId, appId) => {
set(state => ({
instances: state.instances.map(item => item.id === instanceId ? { ...item, appId, bindingProfileId: appId } : item),
}))
},
deleteInstance: (instanceId) => {
set(state => ({
instances: state.instances.filter(item => item.id !== instanceId),
deployments: state.deployments.filter(d => d.instanceId !== instanceId),
apiKeys: state.apiKeys.filter(k => k.instanceId !== instanceId),
access: state.access.filter(a => a.instanceId !== instanceId),
}))
},
startDeploy: ({ instanceId, environmentId, releaseId, releaseNote, credentials, envVariables }) => {
const instance = get().instances.find(i => i.id === instanceId)
if (!instance)
return
let targetReleaseId = releaseId
let newRelease: Release | undefined
if (!targetReleaseId) {
const newReleaseId = generateReleaseId()
const trimmedNote = releaseNote?.trim()
newRelease = {
id: newReleaseId,
appId: instance.appId,
gateCommitId: randomGateCommitId(),
operator: 'you',
createdAt: nowStamp(),
description: trimmedNote || 'draft deploy',
yaml: `# Release: ${newReleaseId}\napp:\n name: ${instance.appId}\n mode: advanced-chat\n`,
}
targetReleaseId = newReleaseId
}
const existing = get().deployments.find(d => d.instanceId === instanceId && d.environmentId === environmentId)
let nextDeployments: Deployment[]
let targetDeploymentId: string
if (existing) {
targetDeploymentId = existing.id
nextDeployments = updateDeployment(get().deployments, existing.id, {
status: 'deploying',
targetReleaseId,
failedReleaseId: undefined,
credentials,
envVariables,
errorMessage: undefined,
})
}
else {
targetDeploymentId = `dep-${instanceId}-${environmentId}-${Date.now()}`
const newDeployment: Deployment = {
id: targetDeploymentId,
instanceId,
environmentId,
activeReleaseId: targetReleaseId,
targetReleaseId,
status: 'deploying',
runtimeNote: 'Loading...',
credentials,
envVariables,
createdAt: nowStamp(),
}
nextDeployments = [...get().deployments, newDeployment]
}
set(state => ({
deployments: nextDeployments,
releases: newRelease ? [newRelease, ...state.releases] : state.releases,
deployDrawer: { open: false },
}))
setTimeout(() => {
set(state => ({
deployments: updateDeployment(state.deployments, targetDeploymentId, {
activeReleaseId: targetReleaseId,
targetReleaseId: undefined,
failedReleaseId: undefined,
status: 'ready',
runtimeNote: 'Loaded in memory',
}),
}))
}, DEPLOY_MOCK_DURATION_MS)
},
retryDeploy: (deploymentId) => {
const deployment = get().deployments.find(d => d.id === deploymentId)
if (!deployment)
return
const targetReleaseId = deployment.failedReleaseId ?? deployment.targetReleaseId ?? deployment.activeReleaseId
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
status: 'deploying',
targetReleaseId,
failedReleaseId: undefined,
errorMessage: undefined,
}),
}))
setTimeout(() => {
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
activeReleaseId: targetReleaseId,
targetReleaseId: undefined,
status: 'ready',
runtimeNote: 'Loaded in memory',
}),
}))
}, DEPLOY_MOCK_DURATION_MS)
},
rollbackDeployment: (deploymentId, targetReleaseId) => {
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
status: 'deploying',
targetReleaseId,
failedReleaseId: undefined,
errorMessage: undefined,
}),
rollbackModal: { open: false },
}))
setTimeout(() => {
set(state => ({
deployments: updateDeployment(state.deployments, deploymentId, {
activeReleaseId: targetReleaseId,
targetReleaseId: undefined,
status: 'ready',
runtimeNote: 'Loaded in memory',
}),
}))
}, DEPLOY_MOCK_DURATION_MS)
},
undeployDeployment: (deploymentId) => {
set(state => ({
deployments: state.deployments.filter(d => d.id !== deploymentId),
}))
},
generateApiKey: (instanceId, environmentId) => {
const existingCount = get().apiKeys.filter(k => k.instanceId === instanceId && k.environmentId === environmentId).length
const env = get().environments.find(e => e.id === environmentId)
const labelPrefix = env?.name ?? 'env'
const label = `${labelPrefix}-key-${String(existingCount + 1).padStart(3, '0')}`
const suffix = Math.random().toString(16).slice(2, 12)
const newKey: ApiKey = {
id: generateApiKeyId(),
instanceId,
environmentId,
label,
value: `app-${instanceId.slice(-4)}-${suffix}`,
createdAt: nowStamp(),
}
set(state => ({ apiKeys: [newKey, ...state.apiKeys] }))
},
revokeApiKey: (apiKeyId) => {
set(state => ({
apiKeys: state.apiKeys.filter(k => k.id !== apiKeyId),
}))
},
toggleAccessMethod: (instanceId, method, enabled) => {
set(state => ({
access: state.access.map((a) => {
if (a.instanceId !== instanceId)
return a
return { ...a, enabled: { ...a.enabled, [method]: enabled } }
}),
}))
},
setEnvAccessPermission: (instanceId, environmentId, kind) => {
set(state => ({
access: state.access.map((a) => {
if (a.instanceId !== instanceId)
return a
const existingIdx = a.envPermissions.findIndex(p => p.environmentId === environmentId)
const existing = existingIdx >= 0 ? a.envPermissions[existingIdx] : undefined
const nextEntry = kind === 'specific'
? {
environmentId,
kind,
memberIds: existing?.memberIds ?? [],
groupIds: existing?.groupIds ?? [],
}
: { environmentId, kind }
const envPermissions = existingIdx >= 0
? a.envPermissions.map((p, i) => (i === existingIdx ? nextEntry : p))
: [...a.envPermissions, nextEntry]
return { ...a, envPermissions }
}),
}))
},
setEnvAccessMembers: (instanceId, environmentId, { memberIds, groupIds }) => {
set(state => ({
access: state.access.map((a) => {
if (a.instanceId !== instanceId)
return a
const existingIdx = a.envPermissions.findIndex(p => p.environmentId === environmentId)
const nextEntry = {
environmentId,
kind: 'specific' as AccessPermissionKind,
memberIds,
groupIds,
}
const envPermissions = existingIdx >= 0
? a.envPermissions.map((p, i) => (i === existingIdx ? nextEntry : p))
: [...a.envPermissions, nextEntry]
return { ...a, envPermissions }
}),
}))
},
}))

View File

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

View File

@ -0,0 +1,57 @@
'use client'
import type { AppInfo, AppMode } from './types'
import type { App } from '@/types/app'
import { useEffect, useMemo } from 'react'
import { useAppList } from '@/service/use-apps'
import { useDeploymentsStore } from './store'
const MAX_SOURCE_APPS = 100
function toAppInfo(app: App): AppInfo {
return {
id: app.id,
name: app.name,
mode: app.mode as AppMode,
iconType: app.icon_type === 'image' ? 'image' : 'emoji',
icon: app.icon,
iconBackground: app.icon_background ?? undefined,
iconUrl: app.icon_url,
description: app.description,
}
}
type UseSourceAppsOptions = {
enabled?: boolean
}
export function useSourceApps(options: UseSourceAppsOptions = {}) {
const { enabled = true } = options
const seedInstancesFromApps = useDeploymentsStore(state => state.seedInstancesFromApps)
const { data, isLoading, isFetching, isError } = useAppList({
page: 1,
limit: MAX_SOURCE_APPS,
}, { enabled })
const apps = useMemo<AppInfo[]>(() => {
return (data?.data ?? []).map(toAppInfo)
}, [data?.data])
const appMap = useMemo<Map<string, AppInfo>>(() => {
return new Map(apps.map(a => [a.id, a]))
}, [apps])
useEffect(() => {
if (apps.length > 0)
seedInstancesFromApps(apps)
}, [apps, seedInstancesFromApps])
return {
apps,
appMap,
isLoading,
isFetching,
isError,
isEmpty: !isLoading && apps.length === 0,
}
}

View File

@ -0,0 +1,71 @@
'use client'
import type { NavItem } from '../nav/nav-selector'
import type { AppIconType, AppModeEnum } from '@/types/app'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDeploymentsStore } from '@/app/components/deployments/store'
import { useSourceApps } from '@/app/components/deployments/use-source-apps'
import { useParams, useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import Nav from '../nav'
const DeploymentsNav = () => {
const { t } = useTranslation()
const router = useRouter()
const selectedSegment = useSelectedLayoutSegment()
const isActive = selectedSegment === 'deployments'
const params = useParams<{ instanceId?: string }>()
const instanceId = params?.instanceId
const instances = useDeploymentsStore(state => state.instances)
const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal)
const { appMap } = useSourceApps({ enabled: isActive })
const navigationItems = useMemo<NavItem[]>(() => {
if (!isActive)
return []
return instances.map((instance) => {
const app = appMap.get(instance.appId)
return {
id: instance.id,
name: instance.name,
link: `/deployments/${instance.id}/overview`,
icon_type: (app?.iconType ?? null) as AppIconType | null,
icon: app?.icon ?? '',
icon_background: app?.iconBackground ?? null,
icon_url: app?.iconUrl ?? null,
mode: app?.mode as unknown as AppModeEnum | undefined,
}
})
}, [instances, appMap, isActive])
const curNav = useMemo(() => {
if (!instanceId)
return undefined
return navigationItems.find(item => item.id === instanceId)
}, [instanceId, navigationItems])
const handleCreate = useCallback(() => {
openCreateInstanceModal()
if (selectedSegment !== 'deployments' || instanceId)
router.push('/deployments')
}, [openCreateInstanceModal, router, selectedSegment, instanceId])
return (
<Nav
isApp={false}
icon={<span aria-hidden className="i-ri-rocket-line h-4 w-4" />}
activeIcon={<span aria-hidden className="i-ri-rocket-fill h-4 w-4" />}
text={t('menus.deployments', { ns: 'common' })}
activeSegment="deployments"
link="/deployments"
curNav={curNav}
navigationItems={navigationItems}
createText={t('deployments:createModal.title')}
onCreate={handleCreate}
/>
)
}
export default DeploymentsNav

View File

@ -15,6 +15,7 @@ import { Plan } from '../billing/type'
import AccountDropdown from './account-dropdown'
import AppNav from './app-nav'
import DatasetNav from './dataset-nav'
import DeploymentsNav from './deployments-nav'
import EnvNav from './env-nav'
import ExploreNav from './explore-nav'
import LicenseNav from './license-env'
@ -85,6 +86,7 @@ const Header = () => {
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{isCurrentWorkspaceEditor && <DeploymentsNav />}
</div>
</div>
)
@ -105,6 +107,7 @@ const Header = () => {
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{isCurrentWorkspaceEditor && <DeploymentsNav />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end pr-3 pl-2 min-[1280px]:pl-3">
<EnvNav />

View File

@ -13,6 +13,7 @@ import type datasetHitTesting from '../i18n/en-US/dataset-hit-testing.json'
import type datasetPipeline from '../i18n/en-US/dataset-pipeline.json'
import type datasetSettings from '../i18n/en-US/dataset-settings.json'
import type dataset from '../i18n/en-US/dataset.json'
import type deployments from '../i18n/en-US/deployments.json'
import type education from '../i18n/en-US/education.json'
import type explore from '../i18n/en-US/explore.json'
import type layout from '../i18n/en-US/layout.json'
@ -46,6 +47,7 @@ export type Resources = {
datasetHitTesting: typeof datasetHitTesting
datasetPipeline: typeof datasetPipeline
datasetSettings: typeof datasetSettings
deployments: typeof deployments
education: typeof education
explore: typeof explore
layout: typeof layout
@ -79,6 +81,7 @@ export const namespaces = [
'datasetHitTesting',
'datasetPipeline',
'datasetSettings',
'deployments',
'education',
'explore',
'layout',

View File

@ -276,6 +276,7 @@
"menus.apps": "Studio",
"menus.datasets": "Knowledge",
"menus.datasetsTips": "COMING SOON: Import your own text data or write data in real-time via Webhook for LLM context enhancement.",
"menus.deployments": "Deployments",
"menus.explore": "Explore",
"menus.exploreMarketplace": "Explore Marketplace",
"menus.newApp": "New App",

View File

@ -0,0 +1,280 @@
{
"access.api.backendTitle": "Backend service API",
"access.api.description": "Access this instance over HTTP. Each API key is scoped to one environment.",
"access.api.developerTitle": "Developer API",
"access.api.disabled": "API access is turned off for this instance.",
"access.api.empty": "Deploy to an environment first to start issuing API keys.",
"access.api.envPrefix": "env: {{env}}",
"access.api.keyList": "API key list",
"access.api.newKey": "New key",
"access.api.newKeyForEnv": "Generate for {{env}}",
"access.api.noKeys": "No API keys yet. Create one to start calling the API.",
"access.api.title": "API",
"access.channels.description": "WebApp and CLI entry points use the access permissions above.",
"access.channels.disabled": "Access channels are turned off for this instance.",
"access.channels.followPermission": "Follows permissions",
"access.channels.title": "Access channels",
"access.cli.description": "Use the unified domain when accessing this instance from the CLI.",
"access.cli.docs": "Usage guide",
"access.cli.domain": "Domain",
"access.cli.empty": "CLI endpoint not configured.",
"access.cli.install": "Install CLI",
"access.cli.title": "CLI",
"access.copied": "Copied",
"access.copy": "Copy",
"access.copyFailed": "Copy failed",
"access.copyToast": "Copied to clipboard",
"access.hide": "Hide",
"access.members.clearAll": "Clear all",
"access.members.empty": "No matches found.",
"access.members.groupCount_one": "{{count}} group",
"access.members.groupCount_other": "{{count}} groups",
"access.members.groups": "Groups",
"access.members.individuals": "Members",
"access.members.memberCount_one": "{{count}} member",
"access.members.memberCount_other": "{{count}} members",
"access.members.pickPlaceholder": "Select groups or members",
"access.members.searchPlaceholder": "Search groups and members",
"access.members.selectedLabel": "Selected",
"access.permission.anyone": "Anyone",
"access.permission.anyoneDesc": "Anyone with the link, no login required",
"access.permission.comingSoon": "Coming soon",
"access.permission.external": "Authenticated external users",
"access.permission.externalDesc": "External users who completed SSO/OIDC authentication",
"access.permission.memberCount_one": "{{count}} member",
"access.permission.memberCount_other": "{{count}} members",
"access.permission.organization": "Only members within the organization",
"access.permission.organizationDesc": "All internal members of your workspace",
"access.permission.specific": "Specific members",
"access.permission.specificDesc": "Pick groups or individual members",
"access.permission.specificUnavailable": "Specific member selection is disabled until real workspace subjects are connected.",
"access.permissions.description": "Configure who can access this instance in each deployed environment.",
"access.permissions.title": "Access permissions",
"access.revoke": "Revoke",
"access.runAccess.description": "Manage how users can run this app and who is allowed to access it per environment.",
"access.runAccess.disabled": "Run access is turned off for this instance.",
"access.runAccess.mcp": "MCP",
"access.runAccess.mcpDesc": "Expose this instance as a Model Context Protocol server.",
"access.runAccess.mcpEmpty": "MCP endpoint not configured.",
"access.runAccess.noEnvs": "Deploy to an environment to configure access permissions.",
"access.runAccess.openWebapp": "Open WebApp",
"access.runAccess.permissions": "Access permissions",
"access.runAccess.permissionsDesc": "Who can access this instance in each environment.",
"access.runAccess.title": "Run access",
"access.runAccess.urlLabel": "URL",
"access.runAccess.webapp": "WebApp",
"access.runAccess.webappDesc": "Hosted web page for end users.",
"access.runAccess.webappEmpty": "WebApp URL not configured.",
"access.show": "Show",
"appMode.advanced-chat": "Chatflow",
"appMode.agent-chat": "Agent",
"appMode.chat": "Chatbot",
"appMode.completion": "Completion",
"appMode.workflow": "Workflow",
"card.deploying": "{{count}} deploying",
"card.failed": "{{count}} failed",
"card.fromApp": "From {{name}}",
"card.lastDeployed": "Last deployed {{time}}",
"card.menu.delete": "Delete instance",
"card.menu.deploy": "Deploy to an environment",
"card.menu.viewDetail": "View instance detail",
"card.moreActions": "More actions",
"card.neverDeployed": "Not deployed yet",
"card.notDeployed": "Not deployed",
"card.ready": "{{count}} ready",
"card.tooltip.notDeployed": "This instance has not been deployed to any environment yet.",
"createModal.appPickerPlaceholder": "Select a source app",
"createModal.appSearchEmpty": "No matching apps",
"createModal.appSearchPlaceholder": "Search apps…",
"createModal.cancel": "Cancel",
"createModal.create": "Create",
"createModal.createAndDeploy": "Create and deploy",
"createModal.description": "Pick a source app from Studio and create a deployable instance.",
"createModal.descriptionLabel": "Description",
"createModal.descriptionPlaceholder": "Describe what this instance is used for",
"createModal.loadingApps": "Loading apps…",
"createModal.nameLabel": "Instance name",
"createModal.namePlaceholder": "Instance name",
"createModal.noApps": "No apps found in this workspace. Create one in Studio first.",
"createModal.selected": "Selected",
"createModal.sourceApp": "Source app (required)",
"createModal.title": "Create app instance",
"deployDrawer.cancel": "Cancel",
"deployDrawer.defaultSelect": "Select...",
"deployDrawer.deploy": "Deploy",
"deployDrawer.description": "Create a new release from the current app YAML and deploy it to a target environment.",
"deployDrawer.envVars": "Environment variables",
"deployDrawer.existingReleaseHint": "This existing release will be deployed as-is. No new release will be created.",
"deployDrawer.lockedHint": "Locked to current environment",
"deployDrawer.modelCreds": "Model credentials",
"deployDrawer.needsValidation": " (needs validation)",
"deployDrawer.newReleaseHint": "A new release will be created from the current app YAML.",
"deployDrawer.notFound": "Instance not found.",
"deployDrawer.noteLabel": "Release note (optional)",
"deployDrawer.notePlaceholder": "e.g. Ship onboarding copy tweak",
"deployDrawer.pluginCreds": "Plugin credentials",
"deployDrawer.promote": "Promote",
"deployDrawer.promoteDescription": "Deploy an existing release from the version history to a target environment.",
"deployDrawer.promoteTitle": "Promote release",
"deployDrawer.releaseLabel": "Release",
"deployDrawer.runtimeCredentials": "Runtime credentials",
"deployDrawer.secretPlaceholder": "secret",
"deployDrawer.selectEnv": "Select an environment",
"deployDrawer.selectProviderCred": "Select {{provider}} credential",
"deployDrawer.selectProviderKey": "Select {{provider}} key",
"deployDrawer.selectRelease": "Select a release",
"deployDrawer.targetEnv": "Target environment",
"deployDrawer.title": "Deploy to environment",
"deployDrawer.valuePlaceholder": "value",
"deployTab.cancelDeployment": "Cancel deployment",
"deployTab.col.currentRelease": "Current release",
"deployTab.col.environment": "Environment",
"deployTab.col.status": "Status",
"deployTab.col.updated": "Updated",
"deployTab.deployOtherVersion": "Deploy another version",
"deployTab.deployToEnv": "Deploy to {{name}}",
"deployTab.deployToNewEnv": "Deploy to new environment...",
"deployTab.empty": "No deployments yet. Deploy this instance to an environment to get started.",
"deployTab.envCount": "Environments",
"deployTab.moreActions": "More actions",
"deployTab.newDeployment": "New deployment",
"deployTab.panel.commit": "Commit ID",
"deployTab.panel.createdAt": "Created at",
"deployTab.panel.deploymentId": "Deployment ID",
"deployTab.panel.endpoints": "Endpoints",
"deployTab.panel.envVars": "Environment variables",
"deployTab.panel.failedRelease": "Failed release",
"deployTab.panel.health": "Health",
"deployTab.panel.instanceInfo": "Instance info",
"deployTab.panel.modelCreds": "Model credentials",
"deployTab.panel.pluginCreds": "Plugin credentials",
"deployTab.panel.release": "Release",
"deployTab.panel.releaseInfo": "Release info",
"deployTab.panel.replicas": "Replicas",
"deployTab.panel.run": "Run",
"deployTab.panel.runtimeMode": "Runtime mode",
"deployTab.panel.runtimeNote": "Runtime note",
"deployTab.panel.targetRelease": "Target release",
"deployTab.promote": "Promote",
"deployTab.retry": "Retry",
"deployTab.rollback": "Rollback...",
"deployTab.shortcut": "Shortcut",
"deployTab.status.deployingRelease": "Deploying ({{release}})",
"deployTab.status.runningWithFailed": "Running (last deployment failed)",
"deployTab.undeploy": "Undeploy",
"deployTab.undeployFrom": "Undeploy from {{name}}",
"deployTab.viewLogs": "View logs",
"deployTab.viewProgress": "View progress",
"detail.backToInstances": "Back to app instances",
"detail.deployingCount": "{{count}} deploying",
"detail.envCount_one": "{{count}} env",
"detail.envCount_other": "{{count}} envs",
"detail.failedCount": "{{count}} failed",
"detail.notFound": "Instance not found",
"detail.sourceAppDeleted": "Source app deleted",
"documentTitle.detail": "Instance · Deployments",
"documentTitle.list": "Deployments",
"filter.allEnvs": "All environments",
"filter.notDeployed": "Not deployed",
"filter.searchPlaceholder": "Search instances",
"health.degraded": "Degraded",
"health.ready": "Ready",
"mode.isolated": "Isolated",
"mode.shared": "Shared",
"newInstance.comingSoon": "Coming soon",
"newInstance.fromGitHub": "Create from GitHub",
"newInstance.fromStudio": "Select from Studio",
"newInstance.importDSL": "Import DSL",
"newInstance.title": "Create app instance",
"overview.accessStatus": "Access status",
"overview.api": "API",
"overview.apiKeysCount_one": "{{count}} API key",
"overview.apiKeysCount_other": "{{count}} API keys",
"overview.appMode": "App mode",
"overview.basicInfo": "Basic info",
"overview.cli": "CLI",
"overview.configureAccess": "Configure access",
"overview.created": "Created",
"overview.deploy": "Deploy",
"overview.deploymentStatus": "Deployment status",
"overview.description": "Description",
"overview.developerApi": "Developer API",
"overview.disabled": "Disabled",
"overview.emptyValue": "Not set",
"overview.enabled": "Enabled",
"overview.endUserAccess": "End-user access",
"overview.instanceId": "Instance ID",
"overview.manageDeployments": "Manage deployments",
"overview.name": "Name",
"overview.noAccessConfig": "No access configuration.",
"overview.notConfigured": "Not configured",
"overview.notDeployedYet": "Not deployed yet.",
"overview.sourceApp": "Source app",
"overview.sourceAppDeletedDescription": "Historical releases are still deployable, but new releases cannot be generated from the deleted source app. Switch to another source app to continue.",
"overview.sourceAppDeletedTitle": "Source app was deleted",
"overview.sourceAppDeletedValue": "Deleted source app",
"overview.sourceAppUnavailable": "Unavailable",
"overview.switchSourceApp": "Switch source app",
"overview.switchSourceAppDescription": "Choose the Studio app that future releases should use as their DSL source.",
"overview.switchSourceAppHint": "After switching, only newly created releases use the new source app. Historical releases and existing deployments are not changed.",
"overview.viewDeployments": "View deployments",
"overview.webapp": "WebApp",
"rollback.cancel": "Cancel",
"rollback.confirm": "Deploy release",
"rollback.currentRelease": "Current release",
"rollback.description": "Deploying an existing release switches this environment's active release pointer; the underlying Runtime Shell is not rebuilt.",
"rollback.environment": "Environment",
"rollback.impactingBody": "The target release is loaded first, then the pointer is switched. In-flight workers drain before the cutover completes.",
"rollback.impactingTitle": "Impacting action",
"rollback.instance": "Instance",
"rollback.rollbackTo": "Target release",
"rollback.sourceApp": "Source app",
"rollback.title": "Deploy {{release}}",
"settings.danger": "Danger zone",
"settings.dangerDesc": "Deleting an instance removes all associated API keys, access config and deployment history. This action cannot be undone.",
"settings.delete": "Delete instance",
"settings.description": "Description",
"settings.general": "General",
"settings.name": "Instance name",
"settings.reset": "Reset",
"settings.safeToDelete": "No active deployments. Safe to delete.",
"settings.save": "Save changes",
"settings.undeployFirst": "Undeploy from all environments before deleting.",
"settings.updated": "Instance updated",
"status.deployFailed": "Deploy failed",
"status.deploying": "Deploying",
"status.ready": "Ready",
"subtitle": "Deploy and manage your apps across environments.",
"tabs.access.description": "API keys and run-time access for this instance.",
"tabs.access.name": "Access",
"tabs.deploy.description": "Environments this instance is deployed to and their current releases.",
"tabs.deploy.name": "Deploy",
"tabs.overview.description": "Summary of this app instance across all environments.",
"tabs.overview.name": "Overview",
"tabs.settings.description": "Edit instance name, description and other preferences.",
"tabs.settings.name": "Settings",
"tabs.versions.description": "All releases for this app. Deploy any release to an environment.",
"tabs.versions.name": "Versions",
"title": "App instances",
"versions.col.action": "Action",
"versions.col.author": "Author",
"versions.col.commit": "Commit",
"versions.col.createdAt": "Created at",
"versions.col.deployedTo": "Deployed to",
"versions.col.release": "Release",
"versions.commitTooltip": "Commit {{commit}}",
"versions.currentOn": "Current on {{name}}",
"versions.deploy": "Deploy",
"versions.deployTo": "Deploy to {{name}}",
"versions.deployedStatus.active": "Running",
"versions.deployedStatus.deploying": "Deploying",
"versions.deployedStatus.failed": "Failed",
"versions.deployingTo": "{{name}} is deploying",
"versions.empty": "No releases available yet.",
"versions.hideYaml": "Hide YAML",
"versions.moreActions": "More actions",
"versions.promote": "Promote",
"versions.promoteTo": "Promote to {{name}}",
"versions.releaseHistory": "Release history",
"versions.viewYaml": "View YAML"
}

View File

@ -276,6 +276,7 @@
"menus.apps": "工作室",
"menus.datasets": "知识库",
"menus.datasetsTips": "即将到来:上传自己的长文本数据,或通过 Webhook 集成自己的数据源",
"menus.deployments": "部署",
"menus.explore": "探索",
"menus.exploreMarketplace": "探索 Marketplace",
"menus.newApp": "创建应用",

View File

@ -0,0 +1,280 @@
{
"access.api.backendTitle": "后端服务 API",
"access.api.description": "通过 HTTP 调用该实例。每个 API 密钥仅在一个环境中生效。",
"access.api.developerTitle": "开发者 API",
"access.api.disabled": "该实例的 API 接入已关闭。",
"access.api.empty": "请先部署到环境后再签发 API 密钥。",
"access.api.envPrefix": "env{{env}}",
"access.api.keyList": "API Key 列表",
"access.api.newKey": "生成新 Key",
"access.api.newKeyForEnv": "为 {{env}} 生成",
"access.api.noKeys": "尚无 API 密钥,创建一个即可调用 API。",
"access.api.title": "API",
"access.channels.description": "WebApp 与 CLI 入口遵循上方访问权限。",
"access.channels.disabled": "该实例的接入渠道已关闭。",
"access.channels.followPermission": "随权限开放",
"access.channels.title": "接入渠道",
"access.cli.description": "通过 CLI 访问此实例时使用统一域名。",
"access.cli.docs": "使用说明",
"access.cli.domain": "域名",
"access.cli.empty": "尚未配置 CLI 接入地址。",
"access.cli.install": "安装 CLI",
"access.cli.title": "CLI",
"access.copied": "已复制",
"access.copy": "复制",
"access.copyFailed": "复制失败",
"access.copyToast": "已复制到剪贴板",
"access.hide": "隐藏",
"access.members.clearAll": "全部清除",
"access.members.empty": "未找到匹配结果。",
"access.members.groupCount_one": "{{count}} 个分组",
"access.members.groupCount_other": "{{count}} 个分组",
"access.members.groups": "分组",
"access.members.individuals": "成员",
"access.members.memberCount_one": "{{count}} 位成员",
"access.members.memberCount_other": "{{count}} 位成员",
"access.members.pickPlaceholder": "选择分组或成员",
"access.members.searchPlaceholder": "搜索分组和成员",
"access.members.selectedLabel": "已选择",
"access.permission.anyone": "任何人",
"access.permission.anyoneDesc": "任何拥有链接的人,无需登录",
"access.permission.comingSoon": "即将支持",
"access.permission.external": "已认证的外部用户",
"access.permission.externalDesc": "通过 SSO / OIDC 完成认证的外部用户",
"access.permission.memberCount_one": "{{count}} 位成员",
"access.permission.memberCount_other": "{{count}} 位成员",
"access.permission.organization": "仅组织内成员",
"access.permission.organizationDesc": "工作区内所有内部成员",
"access.permission.specific": "特定成员",
"access.permission.specificDesc": "选择指定的分组或单个成员",
"access.permission.specificUnavailable": "特定成员暂未启用,需接入真实工作区成员与分组后再开放。",
"access.permissions.description": "配置该实例在每个已部署环境中的访问人员。",
"access.permissions.title": "访问权限",
"access.revoke": "撤销",
"access.runAccess.description": "管理用户如何运行该应用,以及在每个环境里谁可以访问。",
"access.runAccess.disabled": "该实例的运行时接入已关闭。",
"access.runAccess.mcp": "MCP",
"access.runAccess.mcpDesc": "将此实例作为 Model Context Protocol 服务器对外提供。",
"access.runAccess.mcpEmpty": "尚未配置 MCP 端点。",
"access.runAccess.noEnvs": "请先部署到环境后再配置访问权限。",
"access.runAccess.openWebapp": "打开 WebApp",
"access.runAccess.permissions": "访问权限",
"access.runAccess.permissionsDesc": "每个环境里可以访问该实例的人员。",
"access.runAccess.title": "访问控制Run",
"access.runAccess.urlLabel": "访问地址",
"access.runAccess.webapp": "WebApp",
"access.runAccess.webappDesc": "面向终端用户的托管 Web 页面。",
"access.runAccess.webappEmpty": "尚未配置 WebApp 访问地址。",
"access.show": "显示",
"appMode.advanced-chat": "Chatflow",
"appMode.agent-chat": "Agent",
"appMode.chat": "聊天助手",
"appMode.completion": "文本生成",
"appMode.workflow": "Workflow",
"card.deploying": "{{count}} 个部署中",
"card.failed": "{{count}} 个失败",
"card.fromApp": "来自 {{name}}",
"card.lastDeployed": "上次部署于 {{time}}",
"card.menu.delete": "删除实例",
"card.menu.deploy": "部署到环境",
"card.menu.viewDetail": "查看实例详情",
"card.moreActions": "更多操作",
"card.neverDeployed": "尚未部署",
"card.notDeployed": "未部署",
"card.ready": "{{count}} 个就绪",
"card.tooltip.notDeployed": "该实例尚未部署到任何环境。",
"createModal.appPickerPlaceholder": "选择源应用",
"createModal.appSearchEmpty": "没有匹配的应用",
"createModal.appSearchPlaceholder": "搜索应用…",
"createModal.cancel": "取消",
"createModal.create": "创建",
"createModal.createAndDeploy": "创建并部署",
"createModal.description": "从 Studio 选择一个源应用并创建可部署的实例。",
"createModal.descriptionLabel": "描述",
"createModal.descriptionPlaceholder": "描述该实例的用途",
"createModal.loadingApps": "正在加载应用…",
"createModal.nameLabel": "实例名称",
"createModal.namePlaceholder": "实例名称",
"createModal.noApps": "当前工作区还没有应用,请先在 Studio 创建一个。",
"createModal.selected": "已选择",
"createModal.sourceApp": "源应用(必选)",
"createModal.title": "创建应用实例",
"deployDrawer.cancel": "取消",
"deployDrawer.defaultSelect": "选择...",
"deployDrawer.deploy": "部署",
"deployDrawer.description": "基于当前应用 YAML 创建一个新的发布版本,并部署到目标环境。",
"deployDrawer.envVars": "环境变量",
"deployDrawer.existingReleaseHint": "将直接部署该已有发布版本,不会创建新的版本。",
"deployDrawer.lockedHint": "已锁定至当前环境",
"deployDrawer.modelCreds": "模型凭据",
"deployDrawer.needsValidation": "(待验证)",
"deployDrawer.newReleaseHint": "将基于当前应用 YAML 创建一个新的发布版本。",
"deployDrawer.notFound": "未找到实例。",
"deployDrawer.noteLabel": "发布备注(可选)",
"deployDrawer.notePlaceholder": "例如:优化引导文案",
"deployDrawer.pluginCreds": "插件凭据",
"deployDrawer.promote": "推送",
"deployDrawer.promoteDescription": "从版本历史中选择一个已有发布版本,部署到目标环境。",
"deployDrawer.promoteTitle": "推送发布版本",
"deployDrawer.releaseLabel": "发布版本",
"deployDrawer.runtimeCredentials": "运行时凭据",
"deployDrawer.secretPlaceholder": "机密值",
"deployDrawer.selectEnv": "选择一个环境",
"deployDrawer.selectProviderCred": "选择 {{provider}} 凭据",
"deployDrawer.selectProviderKey": "选择 {{provider}} 密钥",
"deployDrawer.selectRelease": "选择一个发布版本",
"deployDrawer.targetEnv": "目标环境",
"deployDrawer.title": "部署到环境",
"deployDrawer.valuePlaceholder": "值",
"deployTab.cancelDeployment": "取消部署",
"deployTab.col.currentRelease": "当前发布",
"deployTab.col.environment": "环境",
"deployTab.col.status": "状态",
"deployTab.col.updated": "更新时间",
"deployTab.deployOtherVersion": "部署其他版本",
"deployTab.deployToEnv": "部署到 {{name}}",
"deployTab.deployToNewEnv": "部署到新环境...",
"deployTab.empty": "暂无部署。将此实例部署到环境以开始使用。",
"deployTab.envCount": "环境",
"deployTab.moreActions": "更多操作",
"deployTab.newDeployment": "新建部署",
"deployTab.panel.commit": "Commit ID",
"deployTab.panel.createdAt": "创建时间",
"deployTab.panel.deploymentId": "部署 ID",
"deployTab.panel.endpoints": "端点",
"deployTab.panel.envVars": "环境变量",
"deployTab.panel.failedRelease": "失败版本",
"deployTab.panel.health": "健康检查",
"deployTab.panel.instanceInfo": "实例信息",
"deployTab.panel.modelCreds": "模型凭据",
"deployTab.panel.pluginCreds": "插件凭据",
"deployTab.panel.release": "Release",
"deployTab.panel.releaseInfo": "版本信息",
"deployTab.panel.replicas": "副本数",
"deployTab.panel.run": "运行",
"deployTab.panel.runtimeMode": "运行模式",
"deployTab.panel.runtimeNote": "运行时备注",
"deployTab.panel.targetRelease": "目标版本",
"deployTab.promote": "发布",
"deployTab.retry": "重试",
"deployTab.rollback": "回滚...",
"deployTab.shortcut": "快捷",
"deployTab.status.deployingRelease": "部署中({{release}}",
"deployTab.status.runningWithFailed": "运行中(上次部署失败)",
"deployTab.undeploy": "下线",
"deployTab.undeployFrom": "从 {{name}} 取消部署",
"deployTab.viewLogs": "查看日志",
"deployTab.viewProgress": "查看进度",
"detail.backToInstances": "返回应用实例",
"detail.deployingCount": "{{count}} 个部署中",
"detail.envCount_one": "{{count}} 个环境",
"detail.envCount_other": "{{count}} 个环境",
"detail.failedCount": "{{count}} 个失败",
"detail.notFound": "未找到实例",
"detail.sourceAppDeleted": "源应用已删除",
"documentTitle.detail": "实例 · 部署",
"documentTitle.list": "部署",
"filter.allEnvs": "全部环境",
"filter.notDeployed": "未部署",
"filter.searchPlaceholder": "搜索实例",
"health.degraded": "降级",
"health.ready": "就绪",
"mode.isolated": "独立",
"mode.shared": "共享",
"newInstance.comingSoon": "即将支持",
"newInstance.fromGitHub": "从 GitHub 创建",
"newInstance.fromStudio": "从 Studio 选择",
"newInstance.importDSL": "导入 DSL",
"newInstance.title": "创建应用实例",
"overview.accessStatus": "接入状态",
"overview.api": "API",
"overview.apiKeysCount_one": "{{count}} 个 API Key",
"overview.apiKeysCount_other": "{{count}} 个 API Key",
"overview.appMode": "应用类型",
"overview.basicInfo": "基本信息",
"overview.cli": "CLI",
"overview.configureAccess": "配置接入",
"overview.created": "创建时间",
"overview.deploy": "部署",
"overview.deploymentStatus": "部署状态",
"overview.description": "描述",
"overview.developerApi": "开发者 API",
"overview.disabled": "未启用",
"overview.emptyValue": "未设置",
"overview.enabled": "已启用",
"overview.endUserAccess": "终端用户接入",
"overview.instanceId": "实例 ID",
"overview.manageDeployments": "管理部署",
"overview.name": "名称",
"overview.noAccessConfig": "未配置接入方式。",
"overview.notConfigured": "未配置",
"overview.notDeployedYet": "尚未部署。",
"overview.sourceApp": "源应用",
"overview.sourceAppDeletedDescription": "历史 Release 仍可继续部署,但无法再基于已删除的源应用生成新 Release。请切换到其他源应用后继续使用。",
"overview.sourceAppDeletedTitle": "源应用已被删除",
"overview.sourceAppDeletedValue": "已删除的源应用",
"overview.sourceAppUnavailable": "不可用",
"overview.switchSourceApp": "切换源应用",
"overview.switchSourceAppDescription": "选择后续新建 Release 使用的 Studio 应用 DSL 来源。",
"overview.switchSourceAppHint": "切换后,仅新建 Release 会使用新的源应用;历史 Release 和现有部署不受影响。",
"overview.viewDeployments": "查看部署",
"overview.webapp": "WebApp",
"rollback.cancel": "取消",
"rollback.confirm": "确认部署",
"rollback.currentRelease": "当前发布",
"rollback.description": "部署已有发布版本会切换该环境的活动发布指针,不会重建底层 Runtime Shell。",
"rollback.environment": "环境",
"rollback.impactingBody": "先加载目标发布版本,再切换指针。进行中的工作者会先完成排空再执行切换。",
"rollback.impactingTitle": "影响操作",
"rollback.instance": "实例",
"rollback.rollbackTo": "目标发布",
"rollback.sourceApp": "源应用",
"rollback.title": "部署 {{release}}",
"settings.danger": "危险区域",
"settings.dangerDesc": "删除实例会一并删除所有关联的 API 密钥、接入配置和部署历史。此操作无法撤销。",
"settings.delete": "删除实例",
"settings.description": "描述",
"settings.general": "常规",
"settings.name": "实例名称",
"settings.reset": "重置",
"settings.safeToDelete": "无活动部署,可安全删除。",
"settings.save": "保存修改",
"settings.undeployFirst": "请先在所有环境取消部署后再删除。",
"settings.updated": "实例已更新",
"status.deployFailed": "部署失败",
"status.deploying": "部署中",
"status.ready": "就绪",
"subtitle": "在不同环境中部署和管理你的应用。",
"tabs.access.description": "该实例的 API 密钥和运行时访问设置。",
"tabs.access.name": "接入",
"tabs.deploy.description": "该实例已部署的环境及其当前发布版本。",
"tabs.deploy.name": "部署",
"tabs.overview.description": "该应用实例在所有环境中的概览。",
"tabs.overview.name": "概览",
"tabs.settings.description": "编辑实例名称、描述及其它偏好。",
"tabs.settings.name": "设置",
"tabs.versions.description": "此应用的所有发布版本,可将任一版本发布到环境。",
"tabs.versions.name": "版本",
"title": "应用实例",
"versions.col.action": "操作",
"versions.col.author": "作者",
"versions.col.commit": "提交",
"versions.col.createdAt": "创建时间",
"versions.col.deployedTo": "已部署到",
"versions.col.release": "发布",
"versions.commitTooltip": "Commit {{commit}}",
"versions.currentOn": "{{name}} 当前版本",
"versions.deploy": "部署",
"versions.deployTo": "部署到 {{name}}",
"versions.deployedStatus.active": "运行中",
"versions.deployedStatus.deploying": "部署中",
"versions.deployedStatus.failed": "失败",
"versions.deployingTo": "{{name}} 正在部署",
"versions.empty": "暂无可用的发布版本。",
"versions.hideYaml": "隐藏 YAML",
"versions.moreActions": "更多操作",
"versions.promote": "发布",
"versions.promoteTo": "发布到 {{name}}",
"versions.releaseHistory": "发布历史",
"versions.viewYaml": "查看 YAML"
}