mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
feat: init
This commit is contained in:
parent
2677d90860
commit
46e7b5a85a
2
.gitignore
vendored
2
.gitignore
vendored
@ -249,3 +249,5 @@ scripts/stress-test/reports/
|
||||
.qoder/*
|
||||
|
||||
.eslintcache
|
||||
|
||||
temp
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
19
web/app/(commonLayout)/deployments/[instanceId]/layout.tsx
Normal file
19
web/app/(commonLayout)/deployments/[instanceId]/layout.tsx
Normal 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
|
||||
@ -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
|
||||
12
web/app/(commonLayout)/deployments/[instanceId]/page.tsx
Normal file
12
web/app/(commonLayout)/deployments/[instanceId]/page.tsx
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
13
web/app/(commonLayout)/deployments/page.tsx
Normal file
13
web/app/(commonLayout)/deployments/page.tsx
Normal 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)
|
||||
296
web/app/components/deployments/create-instance-modal.tsx
Normal file
296
web/app/components/deployments/create-instance-modal.tsx
Normal 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
|
||||
461
web/app/components/deployments/deploy-drawer.tsx
Normal file
461
web/app/components/deployments/deploy-drawer.tsx
Normal 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
|
||||
523
web/app/components/deployments/index.tsx
Normal file
523
web/app/components/deployments/index.tsx
Normal 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
|
||||
571
web/app/components/deployments/instance-detail/access-tab.tsx
Normal file
571
web/app/components/deployments/instance-detail/access-tab.tsx
Normal 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
|
||||
454
web/app/components/deployments/instance-detail/deploy-tab.tsx
Normal file
454
web/app/components/deployments/instance-detail/deploy-tab.tsx
Normal 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
|
||||
314
web/app/components/deployments/instance-detail/index.tsx
Normal file
314
web/app/components/deployments/instance-detail/index.tsx
Normal 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
|
||||
378
web/app/components/deployments/instance-detail/overview-tab.tsx
Normal file
378
web/app/components/deployments/instance-detail/overview-tab.tsx
Normal 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
|
||||
133
web/app/components/deployments/instance-detail/settings-tab.tsx
Normal file
133
web/app/components/deployments/instance-detail/settings-tab.tsx
Normal 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
|
||||
9
web/app/components/deployments/instance-detail/tabs.ts
Normal file
9
web/app/components/deployments/instance-detail/tabs.ts
Normal 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)
|
||||
}
|
||||
386
web/app/components/deployments/instance-detail/versions-tab.tsx
Normal file
386
web/app/components/deployments/instance-detail/versions-tab.tsx
Normal 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
|
||||
407
web/app/components/deployments/mock-data.ts
Normal file
407
web/app/components/deployments/mock-data.ts
Normal 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: [],
|
||||
},
|
||||
]
|
||||
100
web/app/components/deployments/rollback-modal.tsx
Normal file
100
web/app/components/deployments/rollback-modal.tsx
Normal 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
|
||||
71
web/app/components/deployments/status-badge.tsx
Normal file
71
web/app/components/deployments/status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
431
web/app/components/deployments/store.ts
Normal file
431
web/app/components/deployments/store.ts
Normal 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 }
|
||||
}),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
126
web/app/components/deployments/types.ts
Normal file
126
web/app/components/deployments/types.ts
Normal 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[]
|
||||
}
|
||||
57
web/app/components/deployments/use-source-apps.ts
Normal file
57
web/app/components/deployments/use-source-apps.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
71
web/app/components/header/deployments-nav/index.tsx
Normal file
71
web/app/components/header/deployments-nav/index.tsx
Normal 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
|
||||
@ -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 />
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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",
|
||||
|
||||
280
web/i18n/en-US/deployments.json
Normal file
280
web/i18n/en-US/deployments.json
Normal 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"
|
||||
}
|
||||
@ -276,6 +276,7 @@
|
||||
"menus.apps": "工作室",
|
||||
"menus.datasets": "知识库",
|
||||
"menus.datasetsTips": "即将到来:上传自己的长文本数据,或通过 Webhook 集成自己的数据源",
|
||||
"menus.deployments": "部署",
|
||||
"menus.explore": "探索",
|
||||
"menus.exploreMarketplace": "探索 Marketplace",
|
||||
"menus.newApp": "创建应用",
|
||||
|
||||
280
web/i18n/zh-Hans/deployments.json
Normal file
280
web/i18n/zh-Hans/deployments.json
Normal 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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user