mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
tweaks
This commit is contained in:
parent
6fa77397a4
commit
1aea4e00a4
@ -1,434 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { BindingsProto, ConsoleReleaseSummary, DeploymentSlot, EnvironmentOption } from '@/contract/console/deployments'
|
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { useEffect } from 'react'
|
||||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
|
||||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
|
||||||
import * as React from 'react'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Input from '@/app/components/base/input'
|
import { deploymentAppDataQueryOptions } from '../data'
|
||||||
import { consoleQuery } from '@/service/client'
|
|
||||||
import { deploymentAppDataQueryOptions } from '@/service/deployments'
|
|
||||||
import { useDeploymentsStore } from '../store'
|
import { useDeploymentsStore } from '../store'
|
||||||
import { environmentHealth, environmentMode, environmentName, releaseCommit, releaseLabel } from '../utils'
|
import { DeployForm } from './deploy-drawer/form'
|
||||||
import { HealthBadge, ModeBadge } from './status-badge'
|
|
||||||
|
|
||||||
type CredentialRequirement = {
|
|
||||||
slot: string
|
|
||||||
label: string
|
|
||||||
required: boolean
|
|
||||||
selectedCredentialId?: string
|
|
||||||
options: { id: string, label: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnvVarRequirement = {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
required: boolean
|
|
||||||
selectedEnvVarId?: string
|
|
||||||
type: 'string' | 'secret'
|
|
||||||
options: { id: string, label: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type RequiredBindings = {
|
|
||||||
model: CredentialRequirement[]
|
|
||||||
plugin: CredentialRequirement[]
|
|
||||||
envVars: EnvVarRequirement[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function isModelSlot(kind?: string) {
|
|
||||||
return kind?.toLowerCase().includes('model')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnvVarSlot(kind?: string) {
|
|
||||||
const normalized = kind?.toLowerCase() ?? ''
|
|
||||||
return normalized.includes('env')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSecretValue(type?: string) {
|
|
||||||
return type?.toLowerCase().includes('secret') ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveRequiredBindings(slots: DeploymentSlot[] | undefined): RequiredBindings {
|
|
||||||
const required: RequiredBindings = {
|
|
||||||
model: [],
|
|
||||||
plugin: [],
|
|
||||||
envVars: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
slots?.forEach((slot) => {
|
|
||||||
const slotName = slot.slot || slot.label
|
|
||||||
if (!slotName)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (isEnvVarSlot(slot.kind)) {
|
|
||||||
required.envVars.push({
|
|
||||||
key: slotName,
|
|
||||||
label: slot.label || slotName,
|
|
||||||
required: slot.required ?? true,
|
|
||||||
selectedEnvVarId: slot.selectedEnvVarId,
|
|
||||||
type: isSecretValue(slot.envVarOptions?.[0]?.valueType) ? 'secret' : 'string',
|
|
||||||
options: slot.envVarOptions
|
|
||||||
?.filter(option => option.id)
|
|
||||||
.map(option => ({
|
|
||||||
id: option.id!,
|
|
||||||
label: `${option.name || option.id}${option.maskedValue ? ` · ${option.maskedValue}` : ''}`,
|
|
||||||
})) ?? [],
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = isModelSlot(slot.kind) ? required.model : required.plugin
|
|
||||||
target.push({
|
|
||||||
slot: slotName,
|
|
||||||
label: slot.label || slotName,
|
|
||||||
required: slot.required ?? true,
|
|
||||||
selectedCredentialId: slot.selectedCredentialId,
|
|
||||||
options: slot.credentialOptions
|
|
||||||
?.filter(option => option.id)
|
|
||||||
.map(option => ({
|
|
||||||
id: option.id!,
|
|
||||||
label: option.displayName || option.provider || option.id!,
|
|
||||||
})) ?? [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return required
|
|
||||||
}
|
|
||||||
|
|
||||||
function credentialValue(values: Record<string, string>, item: CredentialRequirement) {
|
|
||||||
return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function envVarValue(values: Record<string, string>, item: EnvVarRequirement) {
|
|
||||||
return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
type FieldProps = {
|
|
||||||
label: string
|
|
||||||
hint?: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const Field: FC<FieldProps> = ({ label, hint, children }) => (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
|
|
||||||
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
type SelectOption = { value: string, label: string }
|
|
||||||
|
|
||||||
type SelectProps = {
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
options: SelectOption[]
|
|
||||||
placeholder?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeploymentSelect: FC<SelectProps> = ({ value, onChange, options, placeholder }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const selectedOption = useMemo(
|
|
||||||
() => options.find(option => option.value === value),
|
|
||||||
[options, value],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
value={value || null}
|
|
||||||
onValueChange={(next) => {
|
|
||||||
if (!next)
|
|
||||||
return
|
|
||||||
onChange(next)
|
|
||||||
}}
|
|
||||||
disabled={options.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className={cn(
|
|
||||||
'h-8 border-[0.5px] border-components-input-border-active px-2 system-sm-medium',
|
|
||||||
!selectedOption && 'text-text-quaternary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent popupClassName="w-(--anchor-width)">
|
|
||||||
{options.map(opt => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
<SelectItemText>{opt.label}</SelectItemText>
|
|
||||||
<SelectItemIndicator />
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type LabeledSelectProps = SelectProps & { label: string }
|
|
||||||
|
|
||||||
const LabeledSelect: FC<LabeledSelectProps> = ({ label, ...rest }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{label}</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<DeploymentSelect {...rest} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
type EnvironmentRowProps = { env: EnvironmentOption }
|
|
||||||
|
|
||||||
const EnvironmentRow: FC<EnvironmentRowProps> = ({ env }) => (
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
|
|
||||||
<ModeBadge mode={environmentMode(env)} />
|
|
||||||
<HealthBadge health={environmentHealth(env)} />
|
|
||||||
</div>
|
|
||||||
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
type DeployFormProps = {
|
|
||||||
appId: string
|
|
||||||
environments: EnvironmentOption[]
|
|
||||||
releases: ConsoleReleaseSummary[]
|
|
||||||
defaultReleaseId?: string
|
|
||||||
lockedEnvId?: string
|
|
||||||
presetReleaseId?: string
|
|
||||||
onCancel: () => void
|
|
||||||
onSubmit: (params: {
|
|
||||||
environmentId: string
|
|
||||||
releaseId?: string
|
|
||||||
releaseNote?: string
|
|
||||||
bindings?: BindingsProto
|
|
||||||
}) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeployForm: FC<DeployFormProps> = ({
|
|
||||||
appId,
|
|
||||||
environments,
|
|
||||||
releases,
|
|
||||||
defaultReleaseId,
|
|
||||||
lockedEnvId,
|
|
||||||
presetReleaseId,
|
|
||||||
onCancel,
|
|
||||||
onSubmit,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const presetRelease = useMemo(
|
|
||||||
() => presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined,
|
|
||||||
[releases, presetReleaseId],
|
|
||||||
)
|
|
||||||
const isPromote = Boolean(presetRelease)
|
|
||||||
|
|
||||||
const [selectedEnvId, setSelectedEnvId] = useState<string>(
|
|
||||||
() => lockedEnvId ?? environments[0]?.id ?? '',
|
|
||||||
)
|
|
||||||
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
|
|
||||||
const planReleaseId = presetRelease?.id ?? defaultReleaseId ?? releases[0]?.id
|
|
||||||
const deploymentPlan = useQuery(consoleQuery.deployments.deploymentPlan.queryOptions({
|
|
||||||
input: selectedEnvironmentId && planReleaseId
|
|
||||||
? {
|
|
||||||
params: {
|
|
||||||
appId,
|
|
||||||
environmentId: selectedEnvironmentId,
|
|
||||||
releaseId: planReleaseId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: skipToken,
|
|
||||||
}))
|
|
||||||
const required = useMemo(() => deriveRequiredBindings(deploymentPlan.data?.slots), [deploymentPlan.data?.slots])
|
|
||||||
const [releaseNote, setReleaseNote] = useState<string>('')
|
|
||||||
const [modelCredentials, setModelCredentials] = useState<Record<string, string>>({})
|
|
||||||
const [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>({})
|
|
||||||
const [envValues, setEnvValues] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
const canDeploy = Boolean(
|
|
||||||
selectedEnvironmentId
|
|
||||||
&& deploymentPlan.data?.canDeploy !== false
|
|
||||||
&& !deploymentPlan.isFetching
|
|
||||||
&& required.model.every(item => !item.required || credentialValue(modelCredentials, item))
|
|
||||||
&& required.plugin.every(item => !item.required || credentialValue(pluginCredentials, item))
|
|
||||||
&& required.envVars.every(item => !item.required || envVarValue(envValues, item)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
|
|
||||||
|
|
||||||
const handleDeploy = () => {
|
|
||||||
if (!canDeploy)
|
|
||||||
return
|
|
||||||
const bindings: BindingsProto = {
|
|
||||||
models: required.model.map(item => ({
|
|
||||||
slot: item.slot,
|
|
||||||
credentialId: credentialValue(modelCredentials, item),
|
|
||||||
})),
|
|
||||||
plugins: required.plugin.map(item => ({
|
|
||||||
slot: item.slot,
|
|
||||||
credentialId: credentialValue(pluginCredentials, item),
|
|
||||||
})),
|
|
||||||
envVars: required.envVars.map(item => ({
|
|
||||||
slot: item.key,
|
|
||||||
envVarId: envVarValue(envValues, item),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
onSubmit({
|
|
||||||
environmentId: selectedEnvironmentId,
|
|
||||||
releaseId: presetRelease?.id,
|
|
||||||
releaseNote: isPromote ? undefined : releaseNote,
|
|
||||||
bindings,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div>
|
|
||||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
|
||||||
{isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
|
||||||
{isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label={isPromote ? t('deployDrawer.releaseLabel') : t('deployDrawer.noteLabel')}>
|
|
||||||
{isPromote && presetRelease
|
|
||||||
? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(presetRelease)}</span>
|
|
||||||
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
|
||||||
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(presetRelease)}</span>
|
|
||||||
{presetRelease.description && (
|
|
||||||
<>
|
|
||||||
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
|
||||||
<span className="truncate system-xs-regular text-text-secondary">{presetRelease.description}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 system-xs-regular text-text-quaternary">{presetRelease.createdAt}</span>
|
|
||||||
</div>
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('deployDrawer.existingReleaseHint')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Input
|
|
||||||
value={releaseNote}
|
|
||||||
onChange={e => setReleaseNote(e.target.value)}
|
|
||||||
placeholder={t('deployDrawer.notePlaceholder')}
|
|
||||||
maxLength={80}
|
|
||||||
/>
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('deployDrawer.newReleaseHint')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t('deployDrawer.targetEnv')}
|
|
||||||
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
|
|
||||||
>
|
|
||||||
{lockedEnv
|
|
||||||
? <EnvironmentRow env={lockedEnv} />
|
|
||||||
: (
|
|
||||||
<DeploymentSelect
|
|
||||||
value={selectedEnvironmentId}
|
|
||||||
onChange={setSelectedEnvId}
|
|
||||||
options={environments.filter(env => env.id).map(env => ({
|
|
||||||
value: env.id!,
|
|
||||||
label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${(env.type ?? 'env').toUpperCase()}`,
|
|
||||||
}))}
|
|
||||||
placeholder={t('deployDrawer.selectEnv')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{(required.model.length > 0 || required.plugin.length > 0) && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
|
|
||||||
{required.model.length > 0 && (
|
|
||||||
<Field label={t('deployDrawer.modelCreds')}>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{required.model.map((item) => {
|
|
||||||
return (
|
|
||||||
<LabeledSelect
|
|
||||||
key={item.slot}
|
|
||||||
label={item.label}
|
|
||||||
value={credentialValue(modelCredentials, item)}
|
|
||||||
onChange={v => setModelCredentials(prev => ({ ...prev, [item.slot]: v }))}
|
|
||||||
options={item.options.map(option => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.label,
|
|
||||||
}))}
|
|
||||||
placeholder={t('deployDrawer.selectProviderKey', { provider: item.label })}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{required.plugin.length > 0 && (
|
|
||||||
<Field label={t('deployDrawer.pluginCreds')}>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{required.plugin.map((item) => {
|
|
||||||
return (
|
|
||||||
<LabeledSelect
|
|
||||||
key={item.slot}
|
|
||||||
label={item.label}
|
|
||||||
value={credentialValue(pluginCredentials, item)}
|
|
||||||
onChange={v => setPluginCredentials(prev => ({ ...prev, [item.slot]: v }))}
|
|
||||||
options={item.options.map(option => ({ value: option.id, label: option.label }))}
|
|
||||||
placeholder={t('deployDrawer.selectProviderCred', { provider: item.label })}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{required.envVars.length > 0 && (
|
|
||||||
<Field label={t('deployDrawer.envVars')}>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{required.envVars.map(v => (
|
|
||||||
<div key={v.key} className="flex items-center gap-2">
|
|
||||||
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{v.label}</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<DeploymentSelect
|
|
||||||
value={envVarValue(envValues, v)}
|
|
||||||
onChange={next => setEnvValues(prev => ({ ...prev, [v.key]: next }))}
|
|
||||||
options={v.options.map(option => ({ value: option.id, label: option.label }))}
|
|
||||||
placeholder={t('deployDrawer.defaultSelect')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="secondary" onClick={onCancel}>
|
|
||||||
{t('deployDrawer.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
|
|
||||||
{isPromote ? t('deployDrawer.promote') : t('deployDrawer.deploy')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeployDrawer: FC = () => {
|
const DeployDrawer: FC = () => {
|
||||||
const { t } = useTranslation('deployments')
|
const { t } = useTranslation('deployments')
|
||||||
|
|||||||
114
web/features/deployments/components/deploy-drawer/bindings.ts
Normal file
114
web/features/deployments/components/deploy-drawer/bindings.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import type { BindingsProto, DeploymentSlot } from '@/contract/console/deployments'
|
||||||
|
|
||||||
|
export type CredentialRequirement = {
|
||||||
|
slot: string
|
||||||
|
label: string
|
||||||
|
required: boolean
|
||||||
|
selectedCredentialId?: string
|
||||||
|
options: { id: string, label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnvVarRequirement = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
required: boolean
|
||||||
|
selectedEnvVarId?: string
|
||||||
|
type: 'string' | 'secret'
|
||||||
|
options: { id: string, label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequiredBindings = {
|
||||||
|
model: CredentialRequirement[]
|
||||||
|
plugin: CredentialRequirement[]
|
||||||
|
envVars: EnvVarRequirement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelSlot(kind?: string) {
|
||||||
|
return kind?.toLowerCase().includes('model')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnvVarSlot(kind?: string) {
|
||||||
|
const normalized = kind?.toLowerCase() ?? ''
|
||||||
|
return normalized.includes('env')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSecretValue(type?: string) {
|
||||||
|
return type?.toLowerCase().includes('secret') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveRequiredBindings(slots: DeploymentSlot[] | undefined): RequiredBindings {
|
||||||
|
const required: RequiredBindings = {
|
||||||
|
model: [],
|
||||||
|
plugin: [],
|
||||||
|
envVars: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
slots?.forEach((slot) => {
|
||||||
|
const slotName = slot.slot || slot.label
|
||||||
|
if (!slotName)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (isEnvVarSlot(slot.kind)) {
|
||||||
|
required.envVars.push({
|
||||||
|
key: slotName,
|
||||||
|
label: slot.label || slotName,
|
||||||
|
required: slot.required ?? true,
|
||||||
|
selectedEnvVarId: slot.selectedEnvVarId,
|
||||||
|
type: isSecretValue(slot.envVarOptions?.[0]?.valueType) ? 'secret' : 'string',
|
||||||
|
options: slot.envVarOptions
|
||||||
|
?.filter(option => option.id)
|
||||||
|
.map(option => ({
|
||||||
|
id: option.id!,
|
||||||
|
label: `${option.name || option.id}${option.maskedValue ? ` · ${option.maskedValue}` : ''}`,
|
||||||
|
})) ?? [],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = isModelSlot(slot.kind) ? required.model : required.plugin
|
||||||
|
target.push({
|
||||||
|
slot: slotName,
|
||||||
|
label: slot.label || slotName,
|
||||||
|
required: slot.required ?? true,
|
||||||
|
selectedCredentialId: slot.selectedCredentialId,
|
||||||
|
options: slot.credentialOptions
|
||||||
|
?.filter(option => option.id)
|
||||||
|
.map(option => ({
|
||||||
|
id: option.id!,
|
||||||
|
label: option.displayName || option.provider || option.id!,
|
||||||
|
})) ?? [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return required
|
||||||
|
}
|
||||||
|
|
||||||
|
export function credentialValue(values: Record<string, string>, item: CredentialRequirement) {
|
||||||
|
return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function envVarValue(values: Record<string, string>, item: EnvVarRequirement) {
|
||||||
|
return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deploymentBindings(
|
||||||
|
required: RequiredBindings,
|
||||||
|
modelCredentials: Record<string, string>,
|
||||||
|
pluginCredentials: Record<string, string>,
|
||||||
|
envValues: Record<string, string>,
|
||||||
|
): BindingsProto {
|
||||||
|
return {
|
||||||
|
models: required.model.map(item => ({
|
||||||
|
slot: item.slot,
|
||||||
|
credentialId: credentialValue(modelCredentials, item),
|
||||||
|
})),
|
||||||
|
plugins: required.plugin.map(item => ({
|
||||||
|
slot: item.slot,
|
||||||
|
credentialId: credentialValue(pluginCredentials, item),
|
||||||
|
})),
|
||||||
|
envVars: required.envVars.map(item => ({
|
||||||
|
slot: item.key,
|
||||||
|
envVarId: envVarValue(envValues, item),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
246
web/features/deployments/components/deploy-drawer/form.tsx
Normal file
246
web/features/deployments/components/deploy-drawer/form.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { BindingsProto, ConsoleReleaseSummary, EnvironmentOption } from '@/contract/console/deployments'
|
||||||
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
|
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||||
|
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Input from '@/app/components/base/input'
|
||||||
|
import { consoleQuery } from '@/service/client'
|
||||||
|
import { environmentMode, environmentName, releaseCommit, releaseLabel } from '../../utils'
|
||||||
|
import {
|
||||||
|
credentialValue,
|
||||||
|
deploymentBindings,
|
||||||
|
deriveRequiredBindings,
|
||||||
|
envVarValue,
|
||||||
|
} from './bindings'
|
||||||
|
import {
|
||||||
|
DeploymentSelect,
|
||||||
|
EnvironmentRow,
|
||||||
|
Field,
|
||||||
|
LabeledSelect,
|
||||||
|
} from './select'
|
||||||
|
|
||||||
|
export type DeployFormSubmit = {
|
||||||
|
environmentId: string
|
||||||
|
releaseId?: string
|
||||||
|
releaseNote?: string
|
||||||
|
bindings?: BindingsProto
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeployFormProps = {
|
||||||
|
appId: string
|
||||||
|
environments: EnvironmentOption[]
|
||||||
|
releases: ConsoleReleaseSummary[]
|
||||||
|
defaultReleaseId?: string
|
||||||
|
lockedEnvId?: string
|
||||||
|
presetReleaseId?: string
|
||||||
|
onCancel: () => void
|
||||||
|
onSubmit: (params: DeployFormSubmit) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeployForm: FC<DeployFormProps> = ({
|
||||||
|
appId,
|
||||||
|
environments,
|
||||||
|
releases,
|
||||||
|
defaultReleaseId,
|
||||||
|
lockedEnvId,
|
||||||
|
presetReleaseId,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const presetRelease = useMemo(
|
||||||
|
() => presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined,
|
||||||
|
[releases, presetReleaseId],
|
||||||
|
)
|
||||||
|
const isPromote = Boolean(presetRelease)
|
||||||
|
|
||||||
|
const [selectedEnvId, setSelectedEnvId] = useState<string>(
|
||||||
|
() => lockedEnvId ?? environments[0]?.id ?? '',
|
||||||
|
)
|
||||||
|
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
|
||||||
|
const planReleaseId = presetRelease?.id ?? defaultReleaseId ?? releases[0]?.id
|
||||||
|
const deploymentPlan = useQuery(consoleQuery.deployments.deploymentPlan.queryOptions({
|
||||||
|
input: selectedEnvironmentId && planReleaseId
|
||||||
|
? {
|
||||||
|
params: {
|
||||||
|
appId,
|
||||||
|
environmentId: selectedEnvironmentId,
|
||||||
|
releaseId: planReleaseId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: skipToken,
|
||||||
|
}))
|
||||||
|
const required = useMemo(() => deriveRequiredBindings(deploymentPlan.data?.slots), [deploymentPlan.data?.slots])
|
||||||
|
const [releaseNote, setReleaseNote] = useState<string>('')
|
||||||
|
const [modelCredentials, setModelCredentials] = useState<Record<string, string>>({})
|
||||||
|
const [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>({})
|
||||||
|
const [envValues, setEnvValues] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const canDeploy = Boolean(
|
||||||
|
selectedEnvironmentId
|
||||||
|
&& deploymentPlan.data?.canDeploy !== false
|
||||||
|
&& !deploymentPlan.isFetching
|
||||||
|
&& required.model.every(item => !item.required || credentialValue(modelCredentials, item))
|
||||||
|
&& required.plugin.every(item => !item.required || credentialValue(pluginCredentials, item))
|
||||||
|
&& required.envVars.every(item => !item.required || envVarValue(envValues, item)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
|
||||||
|
|
||||||
|
const handleDeploy = () => {
|
||||||
|
if (!canDeploy)
|
||||||
|
return
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
environmentId: selectedEnvironmentId,
|
||||||
|
releaseId: presetRelease?.id,
|
||||||
|
releaseNote: isPromote ? undefined : releaseNote,
|
||||||
|
bindings: deploymentBindings(required, modelCredentials, pluginCredentials, envValues),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||||
|
{isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||||
|
{isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label={isPromote ? t('deployDrawer.releaseLabel') : t('deployDrawer.noteLabel')}>
|
||||||
|
{isPromote && presetRelease
|
||||||
|
? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(presetRelease)}</span>
|
||||||
|
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
||||||
|
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(presetRelease)}</span>
|
||||||
|
{presetRelease.description && (
|
||||||
|
<>
|
||||||
|
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
||||||
|
<span className="truncate system-xs-regular text-text-secondary">{presetRelease.description}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 system-xs-regular text-text-quaternary">{presetRelease.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('deployDrawer.existingReleaseHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
value={releaseNote}
|
||||||
|
onChange={e => setReleaseNote(e.target.value)}
|
||||||
|
placeholder={t('deployDrawer.notePlaceholder')}
|
||||||
|
maxLength={80}
|
||||||
|
/>
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('deployDrawer.newReleaseHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t('deployDrawer.targetEnv')}
|
||||||
|
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
|
||||||
|
>
|
||||||
|
{lockedEnv
|
||||||
|
? <EnvironmentRow env={lockedEnv} />
|
||||||
|
: (
|
||||||
|
<DeploymentSelect
|
||||||
|
value={selectedEnvironmentId}
|
||||||
|
onChange={setSelectedEnvId}
|
||||||
|
options={environments.filter(env => env.id).map(env => ({
|
||||||
|
value: env.id!,
|
||||||
|
label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${(env.type ?? 'env').toUpperCase()}`,
|
||||||
|
}))}
|
||||||
|
placeholder={t('deployDrawer.selectEnv')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{(required.model.length > 0 || required.plugin.length > 0) && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
|
||||||
|
{required.model.length > 0 && (
|
||||||
|
<Field label={t('deployDrawer.modelCreds')}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{required.model.map(item => (
|
||||||
|
<LabeledSelect
|
||||||
|
key={item.slot}
|
||||||
|
label={item.label}
|
||||||
|
value={credentialValue(modelCredentials, item)}
|
||||||
|
onChange={v => setModelCredentials(prev => ({ ...prev, [item.slot]: v }))}
|
||||||
|
options={item.options.map(option => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.label,
|
||||||
|
}))}
|
||||||
|
placeholder={t('deployDrawer.selectProviderKey', { provider: item.label })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{required.plugin.length > 0 && (
|
||||||
|
<Field label={t('deployDrawer.pluginCreds')}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{required.plugin.map(item => (
|
||||||
|
<LabeledSelect
|
||||||
|
key={item.slot}
|
||||||
|
label={item.label}
|
||||||
|
value={credentialValue(pluginCredentials, item)}
|
||||||
|
onChange={v => setPluginCredentials(prev => ({ ...prev, [item.slot]: v }))}
|
||||||
|
options={item.options.map(option => ({ value: option.id, label: option.label }))}
|
||||||
|
placeholder={t('deployDrawer.selectProviderCred', { provider: item.label })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{required.envVars.length > 0 && (
|
||||||
|
<Field label={t('deployDrawer.envVars')}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{required.envVars.map(v => (
|
||||||
|
<div key={v.key} className="flex items-center gap-2">
|
||||||
|
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{v.label}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<DeploymentSelect
|
||||||
|
value={envVarValue(envValues, v)}
|
||||||
|
onChange={next => setEnvValues(prev => ({ ...prev, [v.key]: next }))}
|
||||||
|
options={v.options.map(option => ({ value: option.id, label: option.label }))}
|
||||||
|
placeholder={t('deployDrawer.defaultSelect')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onCancel}>
|
||||||
|
{t('deployDrawer.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
|
||||||
|
{isPromote ? t('deployDrawer.promote') : t('deployDrawer.deploy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
web/features/deployments/components/deploy-drawer/select.tsx
Normal file
96
web/features/deployments/components/deploy-drawer/select.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { EnvironmentOption } from '@/contract/console/deployments'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { environmentHealth, environmentMode, environmentName } from '../../utils'
|
||||||
|
import { HealthBadge, ModeBadge } from '../status-badge'
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Field: FC<FieldProps> = ({ label, hint, children }) => (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
|
||||||
|
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
type SelectOption = { value: string, label: string }
|
||||||
|
|
||||||
|
type SelectProps = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
options: SelectOption[]
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeploymentSelect: FC<SelectProps> = ({ value, onChange, options, placeholder }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const selectedOption = useMemo(
|
||||||
|
() => options.find(option => option.value === value),
|
||||||
|
[options, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || null}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
if (!next)
|
||||||
|
return
|
||||||
|
onChange(next)
|
||||||
|
}}
|
||||||
|
disabled={options.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
'h-8 border-[0.5px] border-components-input-border-active px-2 system-sm-medium',
|
||||||
|
!selectedOption && 'text-text-quaternary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent popupClassName="w-(--anchor-width)">
|
||||||
|
{options.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
<SelectItemText>{opt.label}</SelectItemText>
|
||||||
|
<SelectItemIndicator />
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabeledSelectProps = SelectProps & { label: string }
|
||||||
|
|
||||||
|
export const LabeledSelect: FC<LabeledSelectProps> = ({ label, ...rest }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{label}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<DeploymentSelect {...rest} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvironmentRowProps = { env: EnvironmentOption }
|
||||||
|
|
||||||
|
export const EnvironmentRow: FC<EnvironmentRowProps> = ({ env }) => (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
|
||||||
|
<ModeBadge mode={environmentMode(env)} />
|
||||||
|
<HealthBadge health={environmentHealth(env)} />
|
||||||
|
</div>
|
||||||
|
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@ -9,7 +9,7 @@ import type {
|
|||||||
} from '@/contract/console/deployments'
|
} from '@/contract/console/deployments'
|
||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
import { getQueryClient } from '@/context/get-query-client'
|
import { getQueryClient } from '@/context/get-query-client'
|
||||||
import { consoleClient } from './client'
|
import { consoleClient } from '@/service/client'
|
||||||
|
|
||||||
const DEPLOYMENT_PAGE_SIZE = 100
|
const DEPLOYMENT_PAGE_SIZE = 100
|
||||||
const DEPLOYMENT_APP_DATA_STALE_TIME = 30 * 1000
|
const DEPLOYMENT_APP_DATA_STALE_TIME = 30 * 1000
|
||||||
@ -1,666 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FC, ReactNode } from 'react'
|
|
||||||
import type { AccessPermissionKind } from '../types'
|
import type { FC } from 'react'
|
||||||
import type {
|
import type {
|
||||||
AccessPolicyDetail,
|
|
||||||
AccessSubject,
|
|
||||||
AccessSubjectDisplay,
|
|
||||||
APIToken,
|
APIToken,
|
||||||
ConsoleEnvironmentSummary,
|
ConsoleEnvironmentSummary,
|
||||||
DeveloperAPIKeySummary,
|
DeveloperAPIKeySummary,
|
||||||
EffectivePolicySummary,
|
|
||||||
} from '@/contract/console/deployments'
|
} from '@/contract/console/deployments'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import {
|
import { useMemo } from 'react'
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@langgenius/dify-ui/dropdown-menu'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
|
||||||
import { Switch } from '@langgenius/dify-ui/switch'
|
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
|
||||||
import { skipToken, useQueries, useQuery } from '@tanstack/react-query'
|
|
||||||
import { useDebounce } from 'ahooks'
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { consoleQuery } from '@/service/client'
|
import { consoleQuery } from '@/service/client'
|
||||||
import { useDeploymentsStore } from '../store'
|
import { useDeploymentsStore } from '../store'
|
||||||
import {
|
import {
|
||||||
accessModeToPermissionKey,
|
|
||||||
deployedRows,
|
deployedRows,
|
||||||
environmentName,
|
environmentName,
|
||||||
permissionKeyToAccessMode,
|
|
||||||
webappUrl,
|
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
|
import { AccessChannelsSection } from './access-tab/channels-section'
|
||||||
type SectionProps = {
|
import { DeveloperApiSection } from './access-tab/developer-api-section'
|
||||||
title: string
|
import { AccessPermissionsSection } from './access-tab/permissions-section'
|
||||||
description?: string
|
import { getUrlOrigin } from './access-tab/url'
|
||||||
action?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const Section: FC<SectionProps> = ({ title, description, action, children }) => (
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="system-sm-semibold text-text-primary">{title}</div>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-1 max-w-xl system-xs-regular text-text-tertiary">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{action}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
type CopyPillProps = {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
prefix?: ReactNode
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const CopyPill: FC<CopyPillProps> = ({ label, value, prefix, className }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value)
|
|
||||||
setCopied(true)
|
|
||||||
toast.success(t('access.copyToast'))
|
|
||||||
window.setTimeout(() => setCopied(false), 1500)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
toast.error(t('access.copyFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mr-0.5 flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 text-[11px] font-medium text-text-tertiary">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
{prefix}
|
|
||||||
<div className="min-w-0 flex-1 truncate px-1 font-mono text-[13px] font-medium text-text-secondary">
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
aria-label={t('access.copy')}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
<span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiKeyRowProps = {
|
|
||||||
apiKey: DeveloperAPIKeySummary
|
|
||||||
onRevoke: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApiKeyRow: FC<ApiKeyRowProps> = ({ apiKey, onRevoke }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const displayValue = apiKey.maskedPrefix || apiKey.id || '—'
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(displayValue)
|
|
||||||
setCopied(true)
|
|
||||||
toast.success(t('access.copyToast'))
|
|
||||||
window.setTimeout(() => setCopied(false), 1500)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
toast.error(t('access.copyFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 py-1.5">
|
|
||||||
<div className="flex min-w-[140px] flex-col">
|
|
||||||
<span className="system-sm-medium text-text-primary">{apiKey.name || apiKey.id}</span>
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.api.envPrefix', { env: apiKey.environmentName || apiKey.environmentId })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
|
|
||||||
<div className="min-w-0 flex-1 truncate font-mono text-[13px] font-medium text-text-secondary">
|
|
||||||
{displayValue}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
aria-label={t('access.copy')}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
<span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRevoke}
|
|
||||||
aria-label={t('access.revoke')}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
|
|
||||||
>
|
|
||||||
<span className="i-ri-delete-bin-line h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionIcon: Record<AccessPermissionKind, string> = {
|
|
||||||
organization: 'i-ri-team-line',
|
|
||||||
specific: 'i-ri-lock-line',
|
|
||||||
anyone: 'i-ri-global-line',
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
|
|
||||||
|
|
||||||
type PermissionPickerProps = {
|
|
||||||
value: AccessPermissionKind
|
|
||||||
disabled?: boolean
|
|
||||||
onChange: (kind: AccessPermissionKind) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const PermissionPicker: FC<PermissionPickerProps> = ({ value, disabled, onChange }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const icon = permissionIcon[value]
|
|
||||||
const label = t(`access.permission.${value}`)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-8 min-w-[220px] items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
|
|
||||||
disabled && 'opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn(icon, 'h-4 w-4 shrink-0 text-text-tertiary')} />
|
|
||||||
<span className="flex-1 truncate text-left">{label}</span>
|
|
||||||
<span className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[340px] p-1">
|
|
||||||
{permissionOrder.map((kind) => {
|
|
||||||
const itemIcon = permissionIcon[kind]
|
|
||||||
const isSelected = kind === value
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={kind}
|
|
||||||
onSelect={() => onChange(kind)}
|
|
||||||
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
|
|
||||||
>
|
|
||||||
<span className={cn(itemIcon, 'mt-0.5 h-4 w-4 shrink-0 text-text-tertiary')} />
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate system-sm-medium text-text-primary">
|
|
||||||
{t(`access.permission.${kind}`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t(`access.permission.${kind}Desc`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isSelected && (
|
|
||||||
<span className="mt-0.5 i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectableAccessSubject = AccessSubjectDisplay & {
|
|
||||||
id: string
|
|
||||||
subjectType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSubject(subject: AccessSubjectDisplay): SelectableAccessSubject | undefined {
|
|
||||||
if (!subject.id || !subject.subjectType)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
...subject,
|
|
||||||
id: subject.id,
|
|
||||||
subjectType: subject.subjectType,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
|
|
||||||
return `${subject.subjectType}:${subject.id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
|
|
||||||
return subjects.map(subject => ({
|
|
||||||
subjectId: subject.id,
|
|
||||||
subjectType: subject.subjectType,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedSubjectsFromPolicy(policy?: AccessPolicyDetail) {
|
|
||||||
const selectedOption = policy?.options?.find(option => option.selected)
|
|
||||||
?? policy?.options?.find(option => option.mode === policy?.accessMode)
|
|
||||||
return [
|
|
||||||
...(selectedOption?.groups ?? []),
|
|
||||||
...(selectedOption?.members ?? []),
|
|
||||||
].map(normalizeSubject).filter((subject): subject is SelectableAccessSubject => Boolean(subject))
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubjectPillProps = {
|
|
||||||
subject: SelectableAccessSubject
|
|
||||||
disabled?: boolean
|
|
||||||
onRemove: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubjectPill: FC<SubjectPillProps> = ({ subject, disabled, onRemove }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const isGroup = subject.subjectType === 'group'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex max-w-full items-center gap-1 rounded-full border border-divider-subtle bg-components-badge-white-to-dark px-2 py-1">
|
|
||||||
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
|
|
||||||
<span className="truncate system-xs-medium text-text-secondary">{subject.name || subject.id}</span>
|
|
||||||
{isGroup && subject.memberCount != null && (
|
|
||||||
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onRemove}
|
|
||||||
aria-label={t('operation.remove', { ns: 'common' })}
|
|
||||||
className={cn(
|
|
||||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-text-quaternary hover:text-text-secondary',
|
|
||||||
disabled && 'cursor-not-allowed opacity-40',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubjectPickerProps = {
|
|
||||||
appId: string
|
|
||||||
disabled?: boolean
|
|
||||||
selectedSubjects: SelectableAccessSubject[]
|
|
||||||
onChange: (subjects: SelectableAccessSubject[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubjectPicker: FC<SubjectPickerProps> = ({
|
|
||||||
appId,
|
|
||||||
disabled,
|
|
||||||
selectedSubjects,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [keyword, setKeyword] = useState('')
|
|
||||||
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
|
|
||||||
const selectedKeys = useMemo(
|
|
||||||
() => new Set(selectedSubjects.map(subjectKey)),
|
|
||||||
[selectedSubjects],
|
|
||||||
)
|
|
||||||
const subjectsQuery = useQuery(consoleQuery.deployments.searchAccessSubjects.queryOptions({
|
|
||||||
input: open
|
|
||||||
? {
|
|
||||||
params: { appId },
|
|
||||||
query: {
|
|
||||||
keyword: debouncedKeyword.trim() || undefined,
|
|
||||||
subjectTypes: ['account', 'group'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: skipToken,
|
|
||||||
staleTime: 30 * 1000,
|
|
||||||
}))
|
|
||||||
const subjects = useMemo(
|
|
||||||
() => subjectsQuery.data?.data
|
|
||||||
?.map(normalizeSubject)
|
|
||||||
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? [],
|
|
||||||
[subjectsQuery.data?.data],
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleSubject = (subject: SelectableAccessSubject) => {
|
|
||||||
const key = subjectKey(subject)
|
|
||||||
if (selectedKeys.has(key)) {
|
|
||||||
if (selectedSubjects.length <= 1)
|
|
||||||
return
|
|
||||||
onChange(selectedSubjects.filter(item => subjectKey(item) !== key))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onChange([...selectedSubjects, subject])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={(
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-8 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
|
||||||
disabled && 'cursor-not-allowed opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="i-ri-add-line h-3.5 w-3.5" />
|
|
||||||
{t('access.members.pickPlaceholder')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{open && (
|
|
||||||
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[360px] p-0">
|
|
||||||
<div className="flex max-h-[420px] flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
|
|
||||||
<div className="border-b border-divider-subtle p-2">
|
|
||||||
<div className="flex h-8 items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
|
|
||||||
<span className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
|
|
||||||
<input
|
|
||||||
value={keyword}
|
|
||||||
onChange={e => setKeyword(e.target.value)}
|
|
||||||
placeholder={t('access.members.searchPlaceholder')}
|
|
||||||
className="min-w-0 flex-1 bg-transparent system-sm-regular text-text-primary outline-none placeholder:text-text-quaternary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-10 overflow-y-auto p-1">
|
|
||||||
{subjectsQuery.isLoading
|
|
||||||
? (
|
|
||||||
<div className="flex h-16 items-center justify-center">
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: subjects.length === 0
|
|
||||||
? (
|
|
||||||
<div className="px-3 py-5 text-center system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.members.empty')}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: subjects.map((subject) => {
|
|
||||||
const isSelected = selectedKeys.has(subjectKey(subject))
|
|
||||||
const isGroup = subject.subjectType === 'group'
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={subjectKey(subject)}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleSubject(subject)}
|
|
||||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left hover:bg-state-base-hover"
|
|
||||||
>
|
|
||||||
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-4 w-4 shrink-0 text-text-tertiary')} />
|
|
||||||
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
|
|
||||||
{subject.name || subject.id}
|
|
||||||
</span>
|
|
||||||
{isGroup && subject.memberCount != null && (
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.members.memberCount', { count: subject.memberCount })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isSelected && (
|
|
||||||
<span className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnvironmentPermissionRowProps = {
|
|
||||||
appId: string
|
|
||||||
environment: ConsoleEnvironmentSummary
|
|
||||||
summaryPolicy?: EffectivePolicySummary
|
|
||||||
onSetPolicy: (
|
|
||||||
appId: string,
|
|
||||||
environmentId: string,
|
|
||||||
channel: string,
|
|
||||||
enabled: boolean,
|
|
||||||
accessMode: string,
|
|
||||||
subjects: AccessSubject[],
|
|
||||||
expectedVersion: number,
|
|
||||||
) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentPermissionRow: FC<EnvironmentPermissionRowProps> = ({
|
|
||||||
appId,
|
|
||||||
environment,
|
|
||||||
summaryPolicy,
|
|
||||||
onSetPolicy,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const environmentId = environment.id
|
|
||||||
const channel = summaryPolicy?.channel ?? 'webapp'
|
|
||||||
const policyQuery = useQuery(consoleQuery.deployments.environmentAccessPolicy.queryOptions({
|
|
||||||
input: environmentId
|
|
||||||
? {
|
|
||||||
params: {
|
|
||||||
appId,
|
|
||||||
environmentId,
|
|
||||||
channel,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: skipToken,
|
|
||||||
staleTime: 30 * 1000,
|
|
||||||
}))
|
|
||||||
const detailPolicy = policyQuery.data?.policy
|
|
||||||
const policyKind = accessModeToPermissionKey(detailPolicy?.accessMode ?? summaryPolicy?.accessMode)
|
|
||||||
const policyFingerprint = [
|
|
||||||
detailPolicy?.id ?? 'new',
|
|
||||||
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
|
|
||||||
detailPolicy?.accessMode ?? summaryPolicy?.accessMode ?? '',
|
|
||||||
].join(':')
|
|
||||||
const policySelectedSubjects = useMemo(
|
|
||||||
() => policyKind === 'specific' ? selectedSubjectsFromPolicy(detailPolicy) : [],
|
|
||||||
[detailPolicy, policyKind],
|
|
||||||
)
|
|
||||||
const [draft, setDraft] = useState<{
|
|
||||||
fingerprint?: string
|
|
||||||
kind?: AccessPermissionKind
|
|
||||||
subjects?: SelectableAccessSubject[]
|
|
||||||
}>({})
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
|
||||||
const hasDraft = draft.fingerprint === policyFingerprint
|
|
||||||
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
|
|
||||||
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
|
|
||||||
|
|
||||||
const persistPolicy = async (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
|
|
||||||
if (!environmentId)
|
|
||||||
return
|
|
||||||
if (nextKind === 'specific' && nextSubjects.length === 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
await onSetPolicy(
|
|
||||||
appId,
|
|
||||||
environmentId,
|
|
||||||
detailPolicy?.channel ?? channel,
|
|
||||||
detailPolicy?.enabled ?? summaryPolicy?.enabled ?? true,
|
|
||||||
permissionKeyToAccessMode(nextKind),
|
|
||||||
nextKind === 'specific' ? policySubjects(nextSubjects) : [],
|
|
||||||
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
|
|
||||||
)
|
|
||||||
await policyQuery.refetch()
|
|
||||||
setDraft({})
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
toast.error(t('access.permission.updateFailed'))
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
|
|
||||||
setDraft({
|
|
||||||
fingerprint: policyFingerprint,
|
|
||||||
kind: nextKind,
|
|
||||||
subjects: nextKind === 'specific' ? subjects : [],
|
|
||||||
})
|
|
||||||
if (nextKind === 'specific') {
|
|
||||||
void persistPolicy(nextKind, subjects)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void persistPolicy(nextKind, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
|
|
||||||
if (nextSubjects.length === 0)
|
|
||||||
return
|
|
||||||
setDraft({
|
|
||||||
fingerprint: policyFingerprint,
|
|
||||||
kind: 'specific',
|
|
||||||
subjects: nextSubjects,
|
|
||||||
})
|
|
||||||
void persistPolicy('specific', nextSubjects)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
||||||
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
|
|
||||||
{environmentName(environment)}
|
|
||||||
</span>
|
|
||||||
<PermissionPicker
|
|
||||||
value={permissionKind}
|
|
||||||
disabled={isSaving || policyQuery.isLoading}
|
|
||||||
onChange={handlePermissionChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{permissionKind === 'specific' && (
|
|
||||||
<div className="flex flex-col gap-2 pl-0 sm:pl-[152px]">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<SubjectPicker
|
|
||||||
appId={appId}
|
|
||||||
selectedSubjects={subjects}
|
|
||||||
disabled={isSaving || policyQuery.isLoading}
|
|
||||||
onChange={handleSubjectsChange}
|
|
||||||
/>
|
|
||||||
{subjects.length === 0 && (
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.members.emptySelection')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{subjects.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{subjects.map(subject => (
|
|
||||||
<SubjectPill
|
|
||||||
key={subjectKey(subject)}
|
|
||||||
subject={subject}
|
|
||||||
disabled={isSaving || subjects.length <= 1}
|
|
||||||
onRemove={() => handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type EndpointRowProps = {
|
|
||||||
envName: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
openLabel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EndpointRow: FC<EndpointRowProps> = ({ envName, label, value, openLabel }) => (
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
||||||
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
|
|
||||||
{envName}
|
|
||||||
</span>
|
|
||||||
<CopyPill label={label} value={value} className="min-w-[260px] flex-1" />
|
|
||||||
{openLabel && (
|
|
||||||
<a
|
|
||||||
href={value}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
|
||||||
>
|
|
||||||
<span className="i-ri-external-link-line h-3.5 w-3.5" />
|
|
||||||
{openLabel}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
type ApiKeyGenerateMenuProps = {
|
|
||||||
environments: ConsoleEnvironmentSummary[]
|
|
||||||
onGenerate: (environmentId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGenerate }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const selectableEnvironments = environments.filter(env => env.id)
|
|
||||||
const disabled = selectableEnvironments.length === 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 system-sm-medium',
|
|
||||||
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text',
|
|
||||||
'hover:bg-components-button-secondary-bg-hover',
|
|
||||||
disabled && 'cursor-not-allowed opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="i-ri-add-line h-3.5 w-3.5" />
|
|
||||||
{t('access.api.newKey')}
|
|
||||||
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
{open && !disabled && (
|
|
||||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
|
|
||||||
{selectableEnvironments.map(env => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={env.id}
|
|
||||||
className="gap-2 px-3"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
onGenerate(env.id!)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="system-sm-regular text-text-secondary">
|
|
||||||
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
)}
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUrlOrigin(url?: string) {
|
|
||||||
if (!url)
|
|
||||||
return undefined
|
|
||||||
try {
|
|
||||||
return new URL(url).origin
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueEnvironments(environments: (ConsoleEnvironmentSummary | undefined)[]) {
|
function uniqueEnvironments(environments: (ConsoleEnvironmentSummary | undefined)[]) {
|
||||||
return environments.filter((environment, index): environment is ConsoleEnvironmentSummary => {
|
return environments.filter((environment, index): environment is ConsoleEnvironmentSummary => {
|
||||||
@ -675,7 +32,6 @@ type AccessTabProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
|
const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const appData = useDeploymentsStore(state => state.appData[appId])
|
const appData = useDeploymentsStore(state => state.appData[appId])
|
||||||
const createdApiToken = useDeploymentsStore(state => state.createdApiToken)
|
const createdApiToken = useDeploymentsStore(state => state.createdApiToken)
|
||||||
const clearCreatedApiToken = useDeploymentsStore(state => state.clearCreatedApiToken)
|
const clearCreatedApiToken = useDeploymentsStore(state => state.clearCreatedApiToken)
|
||||||
@ -752,224 +108,29 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 p-6">
|
<div className="flex flex-col gap-5 p-6">
|
||||||
<Section
|
<AccessPermissionsSection
|
||||||
title={t('access.permissions.title')}
|
appId={appId}
|
||||||
description={t('access.permissions.description')}
|
environments={deployedEnvs}
|
||||||
>
|
policies={policies}
|
||||||
{deployedEnvs.length === 0
|
onSetPolicy={setEnvironmentAccessPolicy}
|
||||||
? (
|
/>
|
||||||
<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">
|
<AccessChannelsSection
|
||||||
{t('access.runAccess.noEnvs')}
|
runEnabled={runEnabled}
|
||||||
</div>
|
webappRows={webappRows}
|
||||||
)
|
cliDomain={cliDomain}
|
||||||
: (
|
cliDocsUrl={cliDocsUrl}
|
||||||
<div className="flex flex-col gap-3">
|
onToggle={enabled => toggleAccessChannel(appId, 'webapp', enabled, webappChannelVersion)}
|
||||||
{deployedEnvs.map((env) => {
|
/>
|
||||||
const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy
|
<DeveloperApiSection
|
||||||
return (
|
apiEnabled={apiEnabled}
|
||||||
<EnvironmentPermissionRow
|
environments={deployedEnvs}
|
||||||
key={env.id}
|
apiKeys={apiKeys}
|
||||||
appId={appId}
|
createdToken={visibleCreatedApiToken?.token}
|
||||||
environment={env}
|
onToggle={enabled => toggleAccessChannel(appId, 'api', enabled, 0)}
|
||||||
summaryPolicy={policy}
|
onGenerate={handleGenerateApiKey}
|
||||||
onSetPolicy={setEnvironmentAccessPolicy}
|
onRevoke={handleRevokeApiKey}
|
||||||
/>
|
onClearCreatedToken={clearCreatedApiToken}
|
||||||
)
|
/>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
title={t('access.channels.title')}
|
|
||||||
description={t('access.channels.description')}
|
|
||||||
action={(
|
|
||||||
<Switch
|
|
||||||
checked={runEnabled}
|
|
||||||
onCheckedChange={v => toggleAccessChannel(appId, 'webapp', v, webappChannelVersion)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{runEnabled
|
|
||||||
? (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="system-sm-medium text-text-primary">
|
|
||||||
{t('access.runAccess.webapp')}
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
|
||||||
{t('access.channels.followPermission')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.runAccess.webappDesc')}
|
|
||||||
</div>
|
|
||||||
{webappRows.length > 0
|
|
||||||
? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{webappRows.map((row) => {
|
|
||||||
const endpointUrl = webappUrl(row.url)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EndpointRow
|
|
||||||
key={`webapp-${row.environment?.id ?? row.url}`}
|
|
||||||
envName={environmentName(row.environment)}
|
|
||||||
label={t('access.runAccess.urlLabel')}
|
|
||||||
value={endpointUrl}
|
|
||||||
openLabel={t('access.runAccess.openWebapp')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
|
|
||||||
{t('access.runAccess.webappEmpty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 border-t border-divider-subtle pt-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="system-sm-medium text-text-primary">
|
|
||||||
{t('access.cli.title')}
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
|
||||||
{t('access.channels.followPermission')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.cli.description')}
|
|
||||||
</div>
|
|
||||||
{cliDomain
|
|
||||||
? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<CopyPill
|
|
||||||
label={t('access.cli.domain')}
|
|
||||||
value={cliDomain}
|
|
||||||
className="min-w-[260px] flex-1"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={cliDocsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
|
||||||
>
|
|
||||||
<span className="i-ri-download-cloud-2-line h-3.5 w-3.5" />
|
|
||||||
{t('access.cli.install')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={cliDocsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
|
||||||
>
|
|
||||||
<span className="i-ri-book-open-line h-3.5 w-3.5" />
|
|
||||||
{t('access.cli.docs')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.cli.empty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.channels.disabled')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
title={t('access.api.developerTitle')}
|
|
||||||
description={t('access.api.description')}
|
|
||||||
action={(
|
|
||||||
<Switch
|
|
||||||
checked={apiEnabled}
|
|
||||||
onCheckedChange={v => toggleAccessChannel(appId, 'api', v, 0)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{apiEnabled
|
|
||||||
? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
<span className="system-sm-medium text-text-primary">
|
|
||||||
{t('access.api.backendTitle')}
|
|
||||||
</span>
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.api.keyList')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ApiKeyGenerateMenu
|
|
||||||
environments={deployedEnvs}
|
|
||||||
onGenerate={handleGenerateApiKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{visibleCreatedApiToken && (
|
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
<span className="system-sm-medium text-text-primary">
|
|
||||||
{t('access.api.newTokenTitle')}
|
|
||||||
</span>
|
|
||||||
<span className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.api.newTokenDescription')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearCreatedApiToken}
|
|
||||||
aria-label={t('access.api.dismissToken')}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
<span className="i-ri-close-line h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<CopyPill
|
|
||||||
label={t('access.api.newTokenLabel')}
|
|
||||||
value={visibleCreatedApiToken.token}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{apiKeys.length === 0
|
|
||||||
? (
|
|
||||||
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
|
|
||||||
{deployedEnvs.length === 0
|
|
||||||
? t('access.api.empty')
|
|
||||||
: t('access.api.noKeys')}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
|
||||||
{apiKeys.map((apiKey) => {
|
|
||||||
if (!apiKey.id || !apiKey.environmentId)
|
|
||||||
return null
|
|
||||||
return (
|
|
||||||
<ApiKeyRow
|
|
||||||
key={apiKey.id}
|
|
||||||
apiKey={apiKey}
|
|
||||||
onRevoke={() => handleRevokeApiKey(apiKey.environmentId!, apiKey.id!)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="system-xs-regular text-text-tertiary">
|
|
||||||
{t('access.api.disabled')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
118
web/features/deployments/detail/access-tab/api-keys.tsx
Normal file
118
web/features/deployments/detail/access-tab/api-keys.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { ConsoleEnvironmentSummary, DeveloperAPIKeySummary } from '@/contract/console/deployments'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@langgenius/dify-ui/dropdown-menu'
|
||||||
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { environmentName } from '../../utils'
|
||||||
|
|
||||||
|
type ApiKeyRowProps = {
|
||||||
|
apiKey: DeveloperAPIKeySummary
|
||||||
|
onRevoke: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKeyRow: FC<ApiKeyRowProps> = ({ apiKey, onRevoke }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const displayValue = apiKey.maskedPrefix || apiKey.id || '—'
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(displayValue)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success(t('access.copyToast'))
|
||||||
|
window.setTimeout(() => setCopied(false), 1500)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error(t('access.copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-1.5">
|
||||||
|
<div className="flex min-w-[140px] flex-col">
|
||||||
|
<span className="system-sm-medium text-text-primary">{apiKey.name || apiKey.id}</span>
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.api.envPrefix', { env: apiKey.environmentName || apiKey.environmentId })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
|
||||||
|
<div className="min-w-0 flex-1 truncate font-mono text-[13px] font-medium text-text-secondary">
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label={t('access.copy')}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
<span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRevoke}
|
||||||
|
aria-label={t('access.revoke')}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||||
|
>
|
||||||
|
<span className="i-ri-delete-bin-line h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiKeyGenerateMenuProps = {
|
||||||
|
environments: ConsoleEnvironmentSummary[]
|
||||||
|
onGenerate: (environmentId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGenerate }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const selectableEnvironments = environments.filter(env => env.id)
|
||||||
|
const disabled = selectableEnvironments.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 system-sm-medium',
|
||||||
|
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text',
|
||||||
|
'hover:bg-components-button-secondary-bg-hover',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="i-ri-add-line h-3.5 w-3.5" />
|
||||||
|
{t('access.api.newKey')}
|
||||||
|
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
{open && !disabled && (
|
||||||
|
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
|
||||||
|
{selectableEnvironments.map(env => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={env.id}
|
||||||
|
className="gap-2 px-3"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onGenerate(env.id!)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="system-sm-regular text-text-secondary">
|
||||||
|
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
web/features/deployments/detail/access-tab/channels-section.tsx
Normal file
134
web/features/deployments/detail/access-tab/channels-section.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { WebAppAccessRow } from '@/contract/console/deployments'
|
||||||
|
import { Switch } from '@langgenius/dify-ui/switch'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { environmentName, webappUrl } from '../../utils'
|
||||||
|
import { CopyPill, EndpointRow, Section } from './common'
|
||||||
|
|
||||||
|
type AccessChannelsSectionProps = {
|
||||||
|
runEnabled: boolean
|
||||||
|
webappRows: WebAppAccessRow[]
|
||||||
|
cliDomain?: string
|
||||||
|
cliDocsUrl?: string
|
||||||
|
onToggle: (enabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessChannelsSection: FC<AccessChannelsSectionProps> = ({
|
||||||
|
runEnabled,
|
||||||
|
webappRows,
|
||||||
|
cliDomain,
|
||||||
|
cliDocsUrl,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title={t('access.channels.title')}
|
||||||
|
description={t('access.channels.description')}
|
||||||
|
action={(
|
||||||
|
<Switch
|
||||||
|
checked={runEnabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{runEnabled
|
||||||
|
? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="system-sm-medium text-text-primary">
|
||||||
|
{t('access.runAccess.webapp')}
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
||||||
|
{t('access.channels.followPermission')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.runAccess.webappDesc')}
|
||||||
|
</div>
|
||||||
|
{webappRows.length > 0
|
||||||
|
? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{webappRows.map((row) => {
|
||||||
|
const endpointUrl = webappUrl(row.url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EndpointRow
|
||||||
|
key={`webapp-${row.environment?.id ?? row.url}`}
|
||||||
|
envName={environmentName(row.environment)}
|
||||||
|
label={t('access.runAccess.urlLabel')}
|
||||||
|
value={endpointUrl}
|
||||||
|
openLabel={t('access.runAccess.openWebapp')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
|
||||||
|
{t('access.runAccess.webappEmpty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 border-t border-divider-subtle pt-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="system-sm-medium text-text-primary">
|
||||||
|
{t('access.cli.title')}
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
||||||
|
{t('access.channels.followPermission')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.cli.description')}
|
||||||
|
</div>
|
||||||
|
{cliDomain
|
||||||
|
? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CopyPill
|
||||||
|
label={t('access.cli.domain')}
|
||||||
|
value={cliDomain}
|
||||||
|
className="min-w-[260px] flex-1"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={cliDocsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||||
|
>
|
||||||
|
<span className="i-ri-download-cloud-2-line h-3.5 w-3.5" />
|
||||||
|
{t('access.cli.install')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={cliDocsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||||
|
>
|
||||||
|
<span className="i-ri-book-open-line h-3.5 w-3.5" />
|
||||||
|
{t('access.cli.docs')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.cli.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.channels.disabled')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
web/features/deployments/detail/access-tab/common.tsx
Normal file
106
web/features/deployments/detail/access-tab/common.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type SectionProps = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Section: FC<SectionProps> = ({ title, description, action, children }) => (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="system-sm-semibold text-text-primary">{title}</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 max-w-xl system-xs-regular text-text-tertiary">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
type CopyPillProps = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
prefix?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyPill: FC<CopyPillProps> = ({ label, value, prefix, className }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success(t('access.copyToast'))
|
||||||
|
window.setTimeout(() => setCopied(false), 1500)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error(t('access.copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mr-0.5 flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 text-[11px] font-medium text-text-tertiary">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{prefix}
|
||||||
|
<div className="min-w-0 flex-1 truncate px-1 font-mono text-[13px] font-medium text-text-secondary">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label={t('access.copy')}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
<span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'h-3.5 w-3.5')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndpointRowProps = {
|
||||||
|
envName: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
openLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EndpointRow: FC<EndpointRowProps> = ({ envName, label, value, openLabel }) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
|
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
|
||||||
|
{envName}
|
||||||
|
</span>
|
||||||
|
<CopyPill label={label} value={value} className="min-w-[260px] flex-1" />
|
||||||
|
{openLabel && (
|
||||||
|
<a
|
||||||
|
href={value}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||||
|
>
|
||||||
|
<span className="i-ri-external-link-line h-3.5 w-3.5" />
|
||||||
|
{openLabel}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { ConsoleEnvironmentSummary, DeveloperAPIKeySummary } from '@/contract/console/deployments'
|
||||||
|
import { Switch } from '@langgenius/dify-ui/switch'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ApiKeyGenerateMenu, ApiKeyRow } from './api-keys'
|
||||||
|
import { CopyPill, Section } from './common'
|
||||||
|
|
||||||
|
type DeveloperApiSectionProps = {
|
||||||
|
apiEnabled: boolean
|
||||||
|
environments: ConsoleEnvironmentSummary[]
|
||||||
|
apiKeys: DeveloperAPIKeySummary[]
|
||||||
|
createdToken?: string
|
||||||
|
onToggle: (enabled: boolean) => void
|
||||||
|
onGenerate: (environmentId: string) => void
|
||||||
|
onRevoke: (environmentId: string, apiKeyId: string) => void
|
||||||
|
onClearCreatedToken: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeveloperApiSection: FC<DeveloperApiSectionProps> = ({
|
||||||
|
apiEnabled,
|
||||||
|
environments,
|
||||||
|
apiKeys,
|
||||||
|
createdToken,
|
||||||
|
onToggle,
|
||||||
|
onGenerate,
|
||||||
|
onRevoke,
|
||||||
|
onClearCreatedToken,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title={t('access.api.developerTitle')}
|
||||||
|
description={t('access.api.description')}
|
||||||
|
action={(
|
||||||
|
<Switch
|
||||||
|
checked={apiEnabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{apiEnabled
|
||||||
|
? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="system-sm-medium text-text-primary">
|
||||||
|
{t('access.api.backendTitle')}
|
||||||
|
</span>
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.api.keyList')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ApiKeyGenerateMenu
|
||||||
|
environments={environments}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createdToken && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="system-sm-medium text-text-primary">
|
||||||
|
{t('access.api.newTokenTitle')}
|
||||||
|
</span>
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.api.newTokenDescription')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearCreatedToken}
|
||||||
|
aria-label={t('access.api.dismissToken')}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
<span className="i-ri-close-line h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<CopyPill
|
||||||
|
label={t('access.api.newTokenLabel')}
|
||||||
|
value={createdToken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{apiKeys.length === 0
|
||||||
|
? (
|
||||||
|
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
|
||||||
|
{environments.length === 0
|
||||||
|
? t('access.api.empty')
|
||||||
|
: t('access.api.noKeys')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||||
|
{apiKeys.map((apiKey) => {
|
||||||
|
if (!apiKey.id || !apiKey.environmentId)
|
||||||
|
return null
|
||||||
|
return (
|
||||||
|
<ApiKeyRow
|
||||||
|
key={apiKey.id}
|
||||||
|
apiKey={apiKey}
|
||||||
|
onRevoke={() => onRevoke(apiKey.environmentId!, apiKey.id!)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.api.disabled')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { AccessSubject, ConsoleEnvironmentSummary, EnvironmentPolicySummary } from '@/contract/console/deployments'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Section } from './common'
|
||||||
|
import { EnvironmentPermissionRow } from './permissions'
|
||||||
|
|
||||||
|
type AccessPermissionsSectionProps = {
|
||||||
|
appId: string
|
||||||
|
environments: ConsoleEnvironmentSummary[]
|
||||||
|
policies: EnvironmentPolicySummary[]
|
||||||
|
onSetPolicy: (
|
||||||
|
appId: string,
|
||||||
|
environmentId: string,
|
||||||
|
channel: string,
|
||||||
|
enabled: boolean,
|
||||||
|
accessMode: string,
|
||||||
|
subjects: AccessSubject[],
|
||||||
|
expectedVersion: number,
|
||||||
|
) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessPermissionsSection: FC<AccessPermissionsSectionProps> = ({
|
||||||
|
appId,
|
||||||
|
environments,
|
||||||
|
policies,
|
||||||
|
onSetPolicy,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title={t('access.permissions.title')}
|
||||||
|
description={t('access.permissions.description')}
|
||||||
|
>
|
||||||
|
{environments.length === 0
|
||||||
|
? (
|
||||||
|
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
|
||||||
|
{t('access.runAccess.noEnvs')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{environments.map((env) => {
|
||||||
|
const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy
|
||||||
|
return (
|
||||||
|
<EnvironmentPermissionRow
|
||||||
|
key={env.id}
|
||||||
|
appId={appId}
|
||||||
|
environment={env}
|
||||||
|
summaryPolicy={policy}
|
||||||
|
onSetPolicy={onSetPolicy}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
445
web/features/deployments/detail/access-tab/permissions.tsx
Normal file
445
web/features/deployments/detail/access-tab/permissions.tsx
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { AccessPermissionKind } from '../../types'
|
||||||
|
import type {
|
||||||
|
AccessPolicyDetail,
|
||||||
|
AccessSubject,
|
||||||
|
AccessSubjectDisplay,
|
||||||
|
ConsoleEnvironmentSummary,
|
||||||
|
EffectivePolicySummary,
|
||||||
|
} from '@/contract/console/deployments'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@langgenius/dify-ui/dropdown-menu'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
|
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||||
|
import { useDebounce } from 'ahooks'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { consoleQuery } from '@/service/client'
|
||||||
|
import {
|
||||||
|
accessModeToPermissionKey,
|
||||||
|
environmentName,
|
||||||
|
permissionKeyToAccessMode,
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
const permissionIcon: Record<AccessPermissionKind, string> = {
|
||||||
|
organization: 'i-ri-team-line',
|
||||||
|
specific: 'i-ri-lock-line',
|
||||||
|
anyone: 'i-ri-global-line',
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
|
||||||
|
|
||||||
|
type PermissionPickerProps = {
|
||||||
|
value: AccessPermissionKind
|
||||||
|
disabled?: boolean
|
||||||
|
onChange: (kind: AccessPermissionKind) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PermissionPicker: FC<PermissionPickerProps> = ({ value, disabled, onChange }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const icon = permissionIcon[value]
|
||||||
|
const label = t(`access.permission.${value}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 min-w-[220px] items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
|
||||||
|
disabled && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(icon, 'h-4 w-4 shrink-0 text-text-tertiary')} />
|
||||||
|
<span className="flex-1 truncate text-left">{label}</span>
|
||||||
|
<span className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent placement="bottom-start" popupClassName="w-[340px] p-1">
|
||||||
|
{permissionOrder.map((kind) => {
|
||||||
|
const itemIcon = permissionIcon[kind]
|
||||||
|
const isSelected = kind === value
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={kind}
|
||||||
|
onSelect={() => onChange(kind)}
|
||||||
|
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
|
||||||
|
>
|
||||||
|
<span className={cn(itemIcon, 'mt-0.5 h-4 w-4 shrink-0 text-text-tertiary')} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate system-sm-medium text-text-primary">
|
||||||
|
{t(`access.permission.${kind}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t(`access.permission.${kind}Desc`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="mt-0.5 i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectableAccessSubject = AccessSubjectDisplay & {
|
||||||
|
id: string
|
||||||
|
subjectType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSubject(subject: AccessSubjectDisplay): SelectableAccessSubject | undefined {
|
||||||
|
if (!subject.id || !subject.subjectType)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...subject,
|
||||||
|
id: subject.id,
|
||||||
|
subjectType: subject.subjectType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
|
||||||
|
return `${subject.subjectType}:${subject.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
|
||||||
|
return subjects.map(subject => ({
|
||||||
|
subjectId: subject.id,
|
||||||
|
subjectType: subject.subjectType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedSubjectsFromPolicy(policy?: AccessPolicyDetail) {
|
||||||
|
const selectedOption = policy?.options?.find(option => option.selected)
|
||||||
|
?? policy?.options?.find(option => option.mode === policy?.accessMode)
|
||||||
|
return [
|
||||||
|
...(selectedOption?.groups ?? []),
|
||||||
|
...(selectedOption?.members ?? []),
|
||||||
|
].map(normalizeSubject).filter((subject): subject is SelectableAccessSubject => Boolean(subject))
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectPillProps = {
|
||||||
|
subject: SelectableAccessSubject
|
||||||
|
disabled?: boolean
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubjectPill: FC<SubjectPillProps> = ({ subject, disabled, onRemove }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const isGroup = subject.subjectType === 'group'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex max-w-full items-center gap-1 rounded-full border border-divider-subtle bg-components-badge-white-to-dark px-2 py-1">
|
||||||
|
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
|
||||||
|
<span className="truncate system-xs-medium text-text-secondary">{subject.name || subject.id}</span>
|
||||||
|
{isGroup && subject.memberCount != null && (
|
||||||
|
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={t('operation.remove', { ns: 'common' })}
|
||||||
|
className={cn(
|
||||||
|
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-text-quaternary hover:text-text-secondary',
|
||||||
|
disabled && 'cursor-not-allowed opacity-40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectPickerProps = {
|
||||||
|
appId: string
|
||||||
|
disabled?: boolean
|
||||||
|
selectedSubjects: SelectableAccessSubject[]
|
||||||
|
onChange: (subjects: SelectableAccessSubject[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubjectPicker: FC<SubjectPickerProps> = ({
|
||||||
|
appId,
|
||||||
|
disabled,
|
||||||
|
selectedSubjects,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
|
||||||
|
const selectedKeys = useMemo(
|
||||||
|
() => new Set(selectedSubjects.map(subjectKey)),
|
||||||
|
[selectedSubjects],
|
||||||
|
)
|
||||||
|
const subjectsQuery = useQuery(consoleQuery.deployments.searchAccessSubjects.queryOptions({
|
||||||
|
input: open
|
||||||
|
? {
|
||||||
|
params: { appId },
|
||||||
|
query: {
|
||||||
|
keyword: debouncedKeyword.trim() || undefined,
|
||||||
|
subjectTypes: ['account', 'group'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: skipToken,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
}))
|
||||||
|
const subjects = useMemo(
|
||||||
|
() => subjectsQuery.data?.data
|
||||||
|
?.map(normalizeSubject)
|
||||||
|
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? [],
|
||||||
|
[subjectsQuery.data?.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleSubject = (subject: SelectableAccessSubject) => {
|
||||||
|
const key = subjectKey(subject)
|
||||||
|
if (selectedKeys.has(key)) {
|
||||||
|
if (selectedSubjects.length <= 1)
|
||||||
|
return
|
||||||
|
onChange(selectedSubjects.filter(item => subjectKey(item) !== key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange([...selectedSubjects, subject])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="i-ri-add-line h-3.5 w-3.5" />
|
||||||
|
{t('access.members.pickPlaceholder')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[360px] p-0">
|
||||||
|
<div className="flex max-h-[420px] flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||||
|
<div className="border-b border-divider-subtle p-2">
|
||||||
|
<div className="flex h-8 items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
|
||||||
|
<span className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
onChange={e => setKeyword(e.target.value)}
|
||||||
|
placeholder={t('access.members.searchPlaceholder')}
|
||||||
|
className="min-w-0 flex-1 bg-transparent system-sm-regular text-text-primary outline-none placeholder:text-text-quaternary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-10 overflow-y-auto p-1">
|
||||||
|
{subjectsQuery.isLoading
|
||||||
|
? (
|
||||||
|
<div className="flex h-16 items-center justify-center">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: subjects.length === 0
|
||||||
|
? (
|
||||||
|
<div className="px-3 py-5 text-center system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.members.empty')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: subjects.map((subject) => {
|
||||||
|
const isSelected = selectedKeys.has(subjectKey(subject))
|
||||||
|
const isGroup = subject.subjectType === 'group'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={subjectKey(subject)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSubject(subject)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left hover:bg-state-base-hover"
|
||||||
|
>
|
||||||
|
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-4 w-4 shrink-0 text-text-tertiary')} />
|
||||||
|
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
|
||||||
|
{subject.name || subject.id}
|
||||||
|
</span>
|
||||||
|
{isGroup && subject.memberCount != null && (
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.members.memberCount', { count: subject.memberCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<span className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvironmentPermissionRowProps = {
|
||||||
|
appId: string
|
||||||
|
environment: ConsoleEnvironmentSummary
|
||||||
|
summaryPolicy?: EffectivePolicySummary
|
||||||
|
onSetPolicy: (
|
||||||
|
appId: string,
|
||||||
|
environmentId: string,
|
||||||
|
channel: string,
|
||||||
|
enabled: boolean,
|
||||||
|
accessMode: string,
|
||||||
|
subjects: AccessSubject[],
|
||||||
|
expectedVersion: number,
|
||||||
|
) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentPermissionRow: FC<EnvironmentPermissionRowProps> = ({
|
||||||
|
appId,
|
||||||
|
environment,
|
||||||
|
summaryPolicy,
|
||||||
|
onSetPolicy,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const environmentId = environment.id
|
||||||
|
const channel = summaryPolicy?.channel ?? 'webapp'
|
||||||
|
const policyQuery = useQuery(consoleQuery.deployments.environmentAccessPolicy.queryOptions({
|
||||||
|
input: environmentId
|
||||||
|
? {
|
||||||
|
params: {
|
||||||
|
appId,
|
||||||
|
environmentId,
|
||||||
|
channel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: skipToken,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
}))
|
||||||
|
const detailPolicy = policyQuery.data?.policy
|
||||||
|
const policyKind = accessModeToPermissionKey(detailPolicy?.accessMode ?? summaryPolicy?.accessMode)
|
||||||
|
const policyFingerprint = [
|
||||||
|
detailPolicy?.id ?? 'new',
|
||||||
|
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
|
||||||
|
detailPolicy?.accessMode ?? summaryPolicy?.accessMode ?? '',
|
||||||
|
].join(':')
|
||||||
|
const policySelectedSubjects = useMemo(
|
||||||
|
() => policyKind === 'specific' ? selectedSubjectsFromPolicy(detailPolicy) : [],
|
||||||
|
[detailPolicy, policyKind],
|
||||||
|
)
|
||||||
|
const [draft, setDraft] = useState<{
|
||||||
|
fingerprint?: string
|
||||||
|
kind?: AccessPermissionKind
|
||||||
|
subjects?: SelectableAccessSubject[]
|
||||||
|
}>({})
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const hasDraft = draft.fingerprint === policyFingerprint
|
||||||
|
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
|
||||||
|
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
|
||||||
|
|
||||||
|
const persistPolicy = async (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
|
||||||
|
if (!environmentId)
|
||||||
|
return
|
||||||
|
if (nextKind === 'specific' && nextSubjects.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await onSetPolicy(
|
||||||
|
appId,
|
||||||
|
environmentId,
|
||||||
|
detailPolicy?.channel ?? channel,
|
||||||
|
detailPolicy?.enabled ?? summaryPolicy?.enabled ?? true,
|
||||||
|
permissionKeyToAccessMode(nextKind),
|
||||||
|
nextKind === 'specific' ? policySubjects(nextSubjects) : [],
|
||||||
|
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
|
||||||
|
)
|
||||||
|
await policyQuery.refetch()
|
||||||
|
setDraft({})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error(t('access.permission.updateFailed'))
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
|
||||||
|
setDraft({
|
||||||
|
fingerprint: policyFingerprint,
|
||||||
|
kind: nextKind,
|
||||||
|
subjects: nextKind === 'specific' ? subjects : [],
|
||||||
|
})
|
||||||
|
if (nextKind === 'specific') {
|
||||||
|
void persistPolicy(nextKind, subjects)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void persistPolicy(nextKind, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
|
||||||
|
if (nextSubjects.length === 0)
|
||||||
|
return
|
||||||
|
setDraft({
|
||||||
|
fingerprint: policyFingerprint,
|
||||||
|
kind: 'specific',
|
||||||
|
subjects: nextSubjects,
|
||||||
|
})
|
||||||
|
void persistPolicy('specific', nextSubjects)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
|
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
|
||||||
|
{environmentName(environment)}
|
||||||
|
</span>
|
||||||
|
<PermissionPicker
|
||||||
|
value={permissionKind}
|
||||||
|
disabled={isSaving || policyQuery.isLoading}
|
||||||
|
onChange={handlePermissionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{permissionKind === 'specific' && (
|
||||||
|
<div className="flex flex-col gap-2 pl-0 sm:pl-[152px]">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<SubjectPicker
|
||||||
|
appId={appId}
|
||||||
|
selectedSubjects={subjects}
|
||||||
|
disabled={isSaving || policyQuery.isLoading}
|
||||||
|
onChange={handleSubjectsChange}
|
||||||
|
/>
|
||||||
|
{subjects.length === 0 && (
|
||||||
|
<span className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('access.members.emptySelection')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{subjects.map(subject => (
|
||||||
|
<SubjectPill
|
||||||
|
key={subjectKey(subject)}
|
||||||
|
subject={subject}
|
||||||
|
disabled={isSaving || subjects.length <= 1}
|
||||||
|
onRemove={() => handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
web/features/deployments/detail/access-tab/url.ts
Normal file
10
web/features/deployments/detail/access-tab/url.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export function getUrlOrigin(url?: string) {
|
||||||
|
if (!url)
|
||||||
|
return undefined
|
||||||
|
try {
|
||||||
|
return new URL(url).origin
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { EnvironmentDeploymentRow } from '@/contract/console/deployments'
|
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import {
|
import {
|
||||||
@ -9,10 +8,8 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@langgenius/dify-ui/dropdown-menu'
|
} from '@langgenius/dify-ui/dropdown-menu'
|
||||||
import * as React from 'react'
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HealthBadge, ModeBadge } from '../components/status-badge'
|
|
||||||
import { useDeploymentsStore } from '../store'
|
import { useDeploymentsStore } from '../store'
|
||||||
import {
|
import {
|
||||||
activeRelease,
|
activeRelease,
|
||||||
@ -20,167 +17,18 @@ import {
|
|||||||
deploymentId,
|
deploymentId,
|
||||||
deploymentStatus,
|
deploymentStatus,
|
||||||
environmentBackend,
|
environmentBackend,
|
||||||
environmentHealth,
|
|
||||||
environmentId,
|
environmentId,
|
||||||
environmentMode,
|
environmentMode,
|
||||||
environmentName,
|
environmentName,
|
||||||
formatDate,
|
|
||||||
releaseCommit,
|
releaseCommit,
|
||||||
releaseLabel,
|
releaseLabel,
|
||||||
targetRelease,
|
targetRelease,
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
|
import { DeploymentPanel } from './deploy-tab/deployment-panel'
|
||||||
|
import { DeploymentStatusSummary } from './deploy-tab/deployment-status-summary'
|
||||||
|
|
||||||
const GRID_TEMPLATE = 'lg:grid-cols-[1.2fr_0.8fr_1fr_auto]'
|
const GRID_TEMPLATE = 'lg:grid-cols-[1.2fr_0.8fr_1fr_auto]'
|
||||||
|
|
||||||
type InfoBlockProps = {
|
|
||||||
title: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfoBlock: FC<InfoBlockProps> = ({ title, children }) => (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
|
|
||||||
<div className="flex flex-col gap-1">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
type InfoRowProps = {
|
|
||||||
label: string
|
|
||||||
value: React.ReactNode
|
|
||||||
mono?: boolean
|
|
||||||
suffix?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfoRow: FC<InfoRowProps> = ({ label, value, mono, suffix }) => (
|
|
||||||
<div className="flex items-start gap-2 py-0.5">
|
|
||||||
<span className="w-24 shrink-0 system-xs-regular text-text-tertiary">{label}</span>
|
|
||||||
<span className={cn('min-w-0 flex-1 system-sm-regular break-words text-text-primary', mono && 'font-mono')}>
|
|
||||||
{value}
|
|
||||||
{suffix && <span className="system-xs-regular text-text-tertiary">{suffix}</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
type DeploymentPanelProps = {
|
|
||||||
row: EnvironmentDeploymentRow
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeploymentPanel: FC<DeploymentPanelProps> = ({ row }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const observed = activeRelease(row)
|
|
||||||
const pending = targetRelease(row)
|
|
||||||
const env = row.environment
|
|
||||||
const observedBindings = row.observedRuntime?.bindings
|
|
||||||
const pendingBindings = row.pendingDeployment?.bindings
|
|
||||||
const credentials = [...observedBindings?.credentials ?? [], ...pendingBindings?.credentials ?? []]
|
|
||||||
const envVars = [...observedBindings?.envVars ?? [], ...pendingBindings?.envVars ?? []]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
|
||||||
<div className="mb-3 flex items-center gap-2">
|
|
||||||
<span className="system-sm-semibold text-text-primary">
|
|
||||||
{environmentName(env)}
|
|
||||||
{' · '}
|
|
||||||
{releaseLabel(observed || pending)}
|
|
||||||
</span>
|
|
||||||
<ModeBadge mode={environmentMode(env)} />
|
|
||||||
<HealthBadge health={environmentHealth(env)} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 md:grid-cols-2">
|
|
||||||
<InfoBlock title={t('deployTab.panel.instanceInfo')}>
|
|
||||||
<InfoRow label={t('deployTab.panel.deploymentId')} value={deploymentId(row) || '—'} mono />
|
|
||||||
<InfoRow label={t('deployTab.panel.replicas')} value={row.instance?.replicas != null ? String(row.instance.replicas) : '—'} />
|
|
||||||
<InfoRow label={t('deployTab.panel.runtimeMode')} value={t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${environmentBackend(env).toUpperCase()}`} />
|
|
||||||
<InfoRow label={t('deployTab.panel.runtimeNote')} value={row.instance?.status ?? '—'} />
|
|
||||||
</InfoBlock>
|
|
||||||
|
|
||||||
<InfoBlock title={t('deployTab.panel.releaseInfo')}>
|
|
||||||
<InfoRow label={t('deployTab.panel.release')} value={releaseLabel(observed || pending)} mono />
|
|
||||||
<InfoRow label={t('deployTab.panel.commit')} value={releaseCommit(observed || pending)} mono />
|
|
||||||
<InfoRow label={t('deployTab.panel.createdAt')} value={formatDate((observed || pending)?.createdAt)} />
|
|
||||||
{pending && (
|
|
||||||
<InfoRow label={t('deployTab.panel.targetRelease')} value={`${releaseLabel(pending)} / ${releaseCommit(pending)}`} mono />
|
|
||||||
)}
|
|
||||||
{row.instance?.lastError?.releaseId && (
|
|
||||||
<InfoRow label={t('deployTab.panel.failedRelease')} value={row.instance.lastError.releaseId} mono />
|
|
||||||
)}
|
|
||||||
</InfoBlock>
|
|
||||||
|
|
||||||
<InfoBlock title={t('deployTab.panel.endpoints')}>
|
|
||||||
<InfoRow label={t('deployTab.panel.run')} value={row.observedRuntime?.endpoints?.run ?? '—'} mono />
|
|
||||||
<InfoRow label={t('deployTab.panel.health')} value={row.observedRuntime?.endpoints?.health ?? '—'} mono />
|
|
||||||
</InfoBlock>
|
|
||||||
|
|
||||||
{credentials.length > 0 && (
|
|
||||||
<InfoBlock title={t('deployTab.panel.modelCreds')}>
|
|
||||||
{credentials.map(c => (
|
|
||||||
<InfoRow
|
|
||||||
key={`${c.slot}-${c.displayName}-${c.maskedValue}`}
|
|
||||||
label={c.slot ?? '—'}
|
|
||||||
value={c.displayName || c.maskedValue || '—'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</InfoBlock>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{envVars.length > 0 && (
|
|
||||||
<InfoBlock title={t('deployTab.panel.envVars')}>
|
|
||||||
{envVars.map(v => (
|
|
||||||
<InfoRow
|
|
||||||
key={`${v.slot}-${v.displayName}`}
|
|
||||||
label={v.slot ?? '—'}
|
|
||||||
value={v.maskedValue || v.displayName || '—'}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</InfoBlock>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{row.instance?.lastError?.message && (
|
|
||||||
<div className="mt-4 rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
|
|
||||||
{row.instance.lastError.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeploymentStatusSummaryProps = {
|
|
||||||
row: EnvironmentDeploymentRow
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ row }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const status = deploymentStatus(row)
|
|
||||||
|
|
||||||
if (status === 'deploying') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
|
|
||||||
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
|
|
||||||
{t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease(row) || activeRelease(row)) })}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'deploy_failed') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-warning-warning-700">
|
|
||||||
<span className="i-ri-alert-line h-3.5 w-3.5" />
|
|
||||||
{t('deployTab.status.runningWithFailed')}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-util-colors-green-green-500" />
|
|
||||||
{t('status.ready')}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeployTabProps = {
|
type DeployTabProps = {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
}
|
}
|
||||||
|
|||||||
134
web/features/deployments/detail/deploy-tab/deployment-panel.tsx
Normal file
134
web/features/deployments/detail/deploy-tab/deployment-panel.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
import type { EnvironmentDeploymentRow } from '@/contract/console/deployments'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { HealthBadge, ModeBadge } from '../../components/status-badge'
|
||||||
|
import {
|
||||||
|
activeRelease,
|
||||||
|
deploymentId,
|
||||||
|
environmentBackend,
|
||||||
|
environmentHealth,
|
||||||
|
environmentMode,
|
||||||
|
environmentName,
|
||||||
|
formatDate,
|
||||||
|
releaseCommit,
|
||||||
|
releaseLabel,
|
||||||
|
targetRelease,
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
type InfoBlockProps = {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoBlock: FC<InfoBlockProps> = ({ title, children }) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
|
||||||
|
<div className="flex flex-col gap-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
type InfoRowProps = {
|
||||||
|
label: string
|
||||||
|
value: ReactNode
|
||||||
|
mono?: boolean
|
||||||
|
suffix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoRow: FC<InfoRowProps> = ({ label, value, mono, suffix }) => (
|
||||||
|
<div className="flex items-start gap-2 py-0.5">
|
||||||
|
<span className="w-24 shrink-0 system-xs-regular text-text-tertiary">{label}</span>
|
||||||
|
<span className={cn('min-w-0 flex-1 system-sm-regular break-words text-text-primary', mono && 'font-mono')}>
|
||||||
|
{value}
|
||||||
|
{suffix && <span className="system-xs-regular text-text-tertiary">{suffix}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeploymentPanelProps = {
|
||||||
|
row: EnvironmentDeploymentRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeploymentPanel: FC<DeploymentPanelProps> = ({ row }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const observed = activeRelease(row)
|
||||||
|
const pending = targetRelease(row)
|
||||||
|
const env = row.environment
|
||||||
|
const observedBindings = row.observedRuntime?.bindings
|
||||||
|
const pendingBindings = row.pendingDeployment?.bindings
|
||||||
|
const credentials = [...observedBindings?.credentials ?? [], ...pendingBindings?.credentials ?? []]
|
||||||
|
const envVars = [...observedBindings?.envVars ?? [], ...pendingBindings?.envVars ?? []]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="system-sm-semibold text-text-primary">
|
||||||
|
{environmentName(env)}
|
||||||
|
{' · '}
|
||||||
|
{releaseLabel(observed || pending)}
|
||||||
|
</span>
|
||||||
|
<ModeBadge mode={environmentMode(env)} />
|
||||||
|
<HealthBadge health={environmentHealth(env)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-x-8 gap-y-4 md:grid-cols-2">
|
||||||
|
<InfoBlock title={t('deployTab.panel.instanceInfo')}>
|
||||||
|
<InfoRow label={t('deployTab.panel.deploymentId')} value={deploymentId(row) || '—'} mono />
|
||||||
|
<InfoRow label={t('deployTab.panel.replicas')} value={row.instance?.replicas != null ? String(row.instance.replicas) : '—'} />
|
||||||
|
<InfoRow label={t('deployTab.panel.runtimeMode')} value={t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${environmentBackend(env).toUpperCase()}`} />
|
||||||
|
<InfoRow label={t('deployTab.panel.runtimeNote')} value={row.instance?.status ?? '—'} />
|
||||||
|
</InfoBlock>
|
||||||
|
|
||||||
|
<InfoBlock title={t('deployTab.panel.releaseInfo')}>
|
||||||
|
<InfoRow label={t('deployTab.panel.release')} value={releaseLabel(observed || pending)} mono />
|
||||||
|
<InfoRow label={t('deployTab.panel.commit')} value={releaseCommit(observed || pending)} mono />
|
||||||
|
<InfoRow label={t('deployTab.panel.createdAt')} value={formatDate((observed || pending)?.createdAt)} />
|
||||||
|
{pending && (
|
||||||
|
<InfoRow label={t('deployTab.panel.targetRelease')} value={`${releaseLabel(pending)} / ${releaseCommit(pending)}`} mono />
|
||||||
|
)}
|
||||||
|
{row.instance?.lastError?.releaseId && (
|
||||||
|
<InfoRow label={t('deployTab.panel.failedRelease')} value={row.instance.lastError.releaseId} mono />
|
||||||
|
)}
|
||||||
|
</InfoBlock>
|
||||||
|
|
||||||
|
<InfoBlock title={t('deployTab.panel.endpoints')}>
|
||||||
|
<InfoRow label={t('deployTab.panel.run')} value={row.observedRuntime?.endpoints?.run ?? '—'} mono />
|
||||||
|
<InfoRow label={t('deployTab.panel.health')} value={row.observedRuntime?.endpoints?.health ?? '—'} mono />
|
||||||
|
</InfoBlock>
|
||||||
|
|
||||||
|
{credentials.length > 0 && (
|
||||||
|
<InfoBlock title={t('deployTab.panel.modelCreds')}>
|
||||||
|
{credentials.map(c => (
|
||||||
|
<InfoRow
|
||||||
|
key={`${c.slot}-${c.displayName}-${c.maskedValue}`}
|
||||||
|
label={c.slot ?? '—'}
|
||||||
|
value={c.displayName || c.maskedValue || '—'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</InfoBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{envVars.length > 0 && (
|
||||||
|
<InfoBlock title={t('deployTab.panel.envVars')}>
|
||||||
|
{envVars.map(v => (
|
||||||
|
<InfoRow
|
||||||
|
key={`${v.slot}-${v.displayName}`}
|
||||||
|
label={v.slot ?? '—'}
|
||||||
|
value={v.maskedValue || v.displayName || '—'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</InfoBlock>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{row.instance?.lastError?.message && (
|
||||||
|
<div className="mt-4 rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
|
||||||
|
{row.instance.lastError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { EnvironmentDeploymentRow } from '@/contract/console/deployments'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
activeRelease,
|
||||||
|
deploymentStatus,
|
||||||
|
releaseLabel,
|
||||||
|
targetRelease,
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
type DeploymentStatusSummaryProps = {
|
||||||
|
row: EnvironmentDeploymentRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ row }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const status = deploymentStatus(row)
|
||||||
|
|
||||||
|
if (status === 'deploying') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
|
||||||
|
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
|
||||||
|
{t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease(row) || activeRelease(row)) })}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'deploy_failed') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-warning-warning-700">
|
||||||
|
<span className="i-ri-alert-line h-3.5 w-3.5" />
|
||||||
|
{t('deployTab.status.runningWithFailed')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-util-colors-green-green-500" />
|
||||||
|
{t('status.ready')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
web/features/deployments/detail/deployment-sidebar.tsx
Normal file
200
web/features/deployments/detail/deployment-sidebar.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ComponentProps, FC, PropsWithoutRef } from 'react'
|
||||||
|
import type { AppInfo } from '../types'
|
||||||
|
import type { InstanceDetailTabKey } from './tabs'
|
||||||
|
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { useHover, useKeyPress } from 'ahooks'
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||||
|
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||||
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
|
import AppIcon from '@/app/components/base/app-icon'
|
||||||
|
import Divider from '@/app/components/base/divider'
|
||||||
|
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||||
|
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||||
|
|
||||||
|
type TabDef = {
|
||||||
|
key: InstanceDetailTabKey
|
||||||
|
icon: NavIcon
|
||||||
|
selectedIcon: NavIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
|
||||||
|
title?: string
|
||||||
|
titleId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const OverviewIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-dashboard-2-line', className)} />
|
||||||
|
const OverviewSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-dashboard-2-fill', className)} />
|
||||||
|
const DeployIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-rocket-line', className)} />
|
||||||
|
const DeploySelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-rocket-fill', className)} />
|
||||||
|
const VersionsIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-stack-line', className)} />
|
||||||
|
const VersionsSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-stack-fill', className)} />
|
||||||
|
const AccessIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-plug-line', className)} />
|
||||||
|
const AccessSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-plug-fill', className)} />
|
||||||
|
const SettingsIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
|
||||||
|
const SettingsSelectedIcon = ({ className }: TailwindNavIconProps) => <span aria-hidden className={cn('i-ri-settings-3-fill', className)} />
|
||||||
|
|
||||||
|
const TABS: TabDef[] = [
|
||||||
|
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
|
||||||
|
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
|
||||||
|
{ key: 'versions', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
|
||||||
|
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
|
||||||
|
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
const isShortcutFromInputArea = (target: EventTarget | null) => {
|
||||||
|
if (!(target instanceof HTMLElement))
|
||||||
|
return false
|
||||||
|
|
||||||
|
return target.tagName === 'INPUT'
|
||||||
|
|| target.tagName === 'TEXTAREA'
|
||||||
|
|| target.isContentEditable
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeploymentSidebarProps = {
|
||||||
|
instanceId: string
|
||||||
|
instanceName: string
|
||||||
|
instanceDescription?: string
|
||||||
|
appModeLabel: string
|
||||||
|
app?: AppInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeploymentSidebar: FC<DeploymentSidebarProps> = ({
|
||||||
|
instanceId,
|
||||||
|
instanceName,
|
||||||
|
instanceDescription,
|
||||||
|
appModeLabel,
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isHoveringSidebar = useHover(sidebarRef)
|
||||||
|
const media = useBreakpoints()
|
||||||
|
const isMobile = media === MediaType.mobile
|
||||||
|
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||||
|
appSidebarExpand: state.appSidebarExpand,
|
||||||
|
setAppSidebarExpand: state.setAppSidebarExpand,
|
||||||
|
})))
|
||||||
|
const sidebarMode = appSidebarExpand || 'expand'
|
||||||
|
const expand = sidebarMode === 'expand'
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setAppSidebarExpand(sidebarMode === 'expand' ? 'collapse' : 'expand')
|
||||||
|
}, [setAppSidebarExpand, sidebarMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const persistedMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||||
|
setAppSidebarExpand(isMobile ? 'collapse' : persistedMode)
|
||||||
|
}, [isMobile, setAppSidebarExpand])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appSidebarExpand)
|
||||||
|
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
|
||||||
|
}, [appSidebarExpand])
|
||||||
|
|
||||||
|
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||||
|
if (isShortcutFromInputArea(e.target))
|
||||||
|
return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
handleToggle()
|
||||||
|
}, { exactMatch: true, useCapture: true })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
|
||||||
|
expand ? 'w-[216px]' : 'w-14',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
|
||||||
|
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{app
|
||||||
|
? (
|
||||||
|
<AppIcon
|
||||||
|
size={expand ? 'large' : 'medium'}
|
||||||
|
iconType={app.iconType}
|
||||||
|
icon={app.icon}
|
||||||
|
background={app.iconBackground}
|
||||||
|
imageUrl={app.iconUrl}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center justify-center rounded-xl border border-divider-subtle bg-background-default text-text-tertiary',
|
||||||
|
expand ? 'h-10 w-10' : 'h-8 w-8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="i-ri-apps-2-line h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expand && (
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<div className="flex w-full">
|
||||||
|
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
|
||||||
|
{instanceName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
|
||||||
|
{appModeLabel}
|
||||||
|
</div>
|
||||||
|
{instanceDescription && (
|
||||||
|
<div
|
||||||
|
className="line-clamp-2 system-xs-regular text-text-tertiary"
|
||||||
|
title={instanceDescription}
|
||||||
|
>
|
||||||
|
{instanceDescription}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative px-4 py-2">
|
||||||
|
<Divider
|
||||||
|
type="horizontal"
|
||||||
|
bgStyle={expand ? 'gradient' : 'solid'}
|
||||||
|
className={cn(
|
||||||
|
'my-0 h-px',
|
||||||
|
expand
|
||||||
|
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
|
||||||
|
: 'bg-divider-subtle',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isMobile && isHoveringSidebar && (
|
||||||
|
<ToggleButton
|
||||||
|
className="absolute top-[-3.5px] -right-3 z-20"
|
||||||
|
expand={expand}
|
||||||
|
handleToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
'flex grow flex-col gap-y-0.5',
|
||||||
|
expand ? 'px-3 py-2' : 'p-3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.key}
|
||||||
|
mode={sidebarMode}
|
||||||
|
iconMap={{ selected: tab.selectedIcon, normal: tab.icon }}
|
||||||
|
name={t(`tabs.${tab.key}.name`)}
|
||||||
|
href={`/deployments/${instanceId}/${tab.key}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,22 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { ComponentProps, FC, PropsWithoutRef, ReactNode } from 'react'
|
|
||||||
import type { AppInfo } from '../types'
|
import type { FC, ReactNode } from 'react'
|
||||||
import type { InstanceDetailTabKey } from './tabs'
|
import type { InstanceDetailTabKey } from './tabs'
|
||||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { useMemo } from 'react'
|
||||||
import { useHover, useKeyPress } from 'ahooks'
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
|
||||||
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
|
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 useDocumentTitle from '@/hooks/use-document-title'
|
||||||
import { useRouter, useSelectedLayoutSegment } from '@/next/navigation'
|
import { useRouter, useSelectedLayoutSegment } from '@/next/navigation'
|
||||||
import DeployDrawer from '../components/deploy-drawer'
|
import DeployDrawer from '../components/deploy-drawer'
|
||||||
@ -25,195 +14,14 @@ import { useDeploymentData } from '../hooks/use-deployment-data'
|
|||||||
import { useSourceApps } from '../hooks/use-source-apps'
|
import { useSourceApps } from '../hooks/use-source-apps'
|
||||||
import { useDeploymentsStore } from '../store'
|
import { useDeploymentsStore } from '../store'
|
||||||
import { deployedRows, deploymentStatus } from '../utils'
|
import { deployedRows, deploymentStatus } from '../utils'
|
||||||
|
import { DeploymentSidebar } from './deployment-sidebar'
|
||||||
import { isInstanceDetailTabKey } from './tabs'
|
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 = {
|
type InstanceDetailProps = {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
children: ReactNode
|
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 InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
|
||||||
const { t } = useTranslation('deployments')
|
const { t } = useTranslation('deployments')
|
||||||
const { t: tCommon } = useTranslation()
|
const { t: tCommon } = useTranslation()
|
||||||
|
|||||||
@ -1,179 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { DeployedToSummary, ReleaseHistoryRow } from '@/contract/console/deployments'
|
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDeploymentsStore } from '../store'
|
import { useDeploymentsStore } from '../store'
|
||||||
import {
|
import {
|
||||||
activeRelease,
|
|
||||||
deployedRows,
|
deployedRows,
|
||||||
deploymentId,
|
|
||||||
deploymentStatus,
|
|
||||||
environmentId,
|
|
||||||
environmentName,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
releaseCommit,
|
releaseCommit,
|
||||||
releaseLabel,
|
releaseLabel,
|
||||||
targetRelease,
|
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
|
import { DeployReleaseMenu } from './versions-tab/deploy-release-menu'
|
||||||
|
import { DeployedToBadge } from './versions-tab/deployed-to-badge'
|
||||||
|
import { getReleaseDeployments } from './versions-tab/release-deployments'
|
||||||
|
|
||||||
const GRID_TEMPLATE = 'grid-cols-[0.9fr_1fr_0.8fr_1.5fr_auto]'
|
const GRID_TEMPLATE = 'grid-cols-[0.9fr_1fr_0.8fr_1.5fr_auto]'
|
||||||
|
|
||||||
type ReleaseDeploymentState = 'active' | 'deploying' | 'failed'
|
|
||||||
|
|
||||||
type ReleaseDeployment = {
|
|
||||||
environmentId: string
|
|
||||||
environmentName: string
|
|
||||||
state: ReleaseDeploymentState
|
|
||||||
}
|
|
||||||
|
|
||||||
const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
|
|
||||||
active: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
|
|
||||||
deploying: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
|
|
||||||
failed: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseDeploymentState(status?: string): ReleaseDeploymentState {
|
|
||||||
const normalized = status?.toLowerCase() ?? ''
|
|
||||||
if (normalized.includes('deploying') || normalized.includes('pending'))
|
|
||||||
return 'deploying'
|
|
||||||
if (normalized.includes('fail') || normalized.includes('error'))
|
|
||||||
return 'failed'
|
|
||||||
return 'active'
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDeployedTo(item: DeployedToSummary): ReleaseDeployment | undefined {
|
|
||||||
if (!item.environmentId)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
environmentId: item.environmentId,
|
|
||||||
environmentName: item.environmentName || item.environmentId,
|
|
||||||
state: releaseDeploymentState(item.instanceStatus),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
|
|
||||||
return items.filter((item, index) => {
|
|
||||||
const key = `${item.environmentId}-${item.state}`
|
|
||||||
return items.findIndex(candidate => `${candidate.environmentId}-${candidate.state}` === key) === index
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeployReleaseMenuProps = {
|
|
||||||
appId: string
|
|
||||||
releaseId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ appId, releaseId }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const appData = useDeploymentsStore(state => state.appData[appId])
|
|
||||||
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
|
|
||||||
const openRollbackModal = useDeploymentsStore(state => state.openRollbackModal)
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const environments = appData?.candidates.environmentOptions?.filter(env => env.id) ?? []
|
|
||||||
const deploymentRows = deployedRows(appData?.environmentDeployments.environmentDeployments)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-7 items-center gap-1 rounded-md px-2 system-xs-medium',
|
|
||||||
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-accent-text',
|
|
||||||
'hover:bg-components-button-secondary-bg-hover',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('versions.deploy')}
|
|
||||||
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
{open && (
|
|
||||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
|
|
||||||
{environments.map((env) => {
|
|
||||||
const envId = env.id!
|
|
||||||
const row = deploymentRows.find(item => environmentId(item.environment) === envId)
|
|
||||||
const isCurrent = activeRelease(row)?.id === releaseId
|
|
||||||
const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false
|
|
||||||
const disabled = Boolean(env.disabled || isCurrent || isEnvironmentDeploying)
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={envId}
|
|
||||||
className="gap-2 px-3"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
if (disabled)
|
|
||||||
return
|
|
||||||
if (row) {
|
|
||||||
openRollbackModal({
|
|
||||||
appId,
|
|
||||||
environmentId: envId,
|
|
||||||
deploymentId: deploymentId(row),
|
|
||||||
targetReleaseId: releaseId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
openDeployDrawer({ appId, environmentId: envId, releaseId })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="system-sm-regular text-text-secondary">
|
|
||||||
{isEnvironmentDeploying
|
|
||||||
? t('versions.deployingTo', { name: environmentName(env) })
|
|
||||||
: isCurrent
|
|
||||||
? t('versions.currentOn', { name: environmentName(env) })
|
|
||||||
: row
|
|
||||||
? t('versions.promoteTo', { name: environmentName(env) })
|
|
||||||
: t('versions.deployTo', { name: environmentName(env) })}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
)}
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeployedToBadge: FC<{ item: ReleaseDeployment }> = ({ item }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const statusLabel = t(`versions.deployedStatus.${item.state}`)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={(
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 system-xs-medium',
|
|
||||||
RELEASE_DEPLOYMENT_STYLES[item.state],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.state === 'deploying'
|
|
||||||
? <span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
|
|
||||||
: item.state === 'failed'
|
|
||||||
? <span className="i-ri-alert-line h-3.5 w-3.5" />
|
|
||||||
: <span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
|
||||||
{item.environmentName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<TooltipContent>
|
|
||||||
{statusLabel}
|
|
||||||
{' · '}
|
|
||||||
{item.environmentName}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type VersionsTabProps = {
|
type VersionsTabProps = {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
}
|
}
|
||||||
@ -190,45 +33,6 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
|
|||||||
[appData?.environmentDeployments.environmentDeployments],
|
[appData?.environmentDeployments.environmentDeployments],
|
||||||
)
|
)
|
||||||
|
|
||||||
const getReleaseDeployments = (row: ReleaseHistoryRow) => {
|
|
||||||
const releaseId = row.release?.id
|
|
||||||
if (!releaseId)
|
|
||||||
return []
|
|
||||||
|
|
||||||
const historyItems = row.deployedTo?.map(fromDeployedTo).filter((item): item is ReleaseDeployment => !!item) ?? []
|
|
||||||
const runtimeItems = deploymentRows.flatMap((deployment) => {
|
|
||||||
const envId = environmentId(deployment.environment)
|
|
||||||
if (!envId)
|
|
||||||
return []
|
|
||||||
|
|
||||||
const items: ReleaseDeployment[] = []
|
|
||||||
if (activeRelease(deployment)?.id === releaseId) {
|
|
||||||
items.push({
|
|
||||||
environmentId: envId,
|
|
||||||
environmentName: environmentName(deployment.environment),
|
|
||||||
state: 'active',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (targetRelease(deployment)?.id === releaseId) {
|
|
||||||
items.push({
|
|
||||||
environmentId: envId,
|
|
||||||
environmentName: environmentName(deployment.environment),
|
|
||||||
state: 'deploying',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (deployment.instance?.lastError?.releaseId === releaseId) {
|
|
||||||
items.push({
|
|
||||||
environmentId: envId,
|
|
||||||
environmentName: environmentName(deployment.environment),
|
|
||||||
state: 'failed',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
})
|
|
||||||
|
|
||||||
return dedupeReleaseDeployments([...historyItems, ...runtimeItems])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 p-6">
|
<div className="flex flex-col gap-4 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -265,7 +69,7 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId: appId }) => {
|
|||||||
|
|
||||||
{releaseRows.map((row) => {
|
{releaseRows.map((row) => {
|
||||||
const release = row.release!
|
const release = row.release!
|
||||||
const releaseDeployments = getReleaseDeployments(row)
|
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
|
||||||
return (
|
return (
|
||||||
<div key={release.id} className="border-b border-divider-subtle last:border-b-0">
|
<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 flex-col gap-3 px-4 py-3 lg:hidden">
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@langgenius/dify-ui/dropdown-menu'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useDeploymentsStore } from '../../store'
|
||||||
|
import {
|
||||||
|
activeRelease,
|
||||||
|
deployedRows,
|
||||||
|
deploymentId,
|
||||||
|
deploymentStatus,
|
||||||
|
environmentId,
|
||||||
|
environmentName,
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
type DeployReleaseMenuProps = {
|
||||||
|
appId: string
|
||||||
|
releaseId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ appId, releaseId }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const appData = useDeploymentsStore(state => state.appData[appId])
|
||||||
|
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
|
||||||
|
const openRollbackModal = useDeploymentsStore(state => state.openRollbackModal)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const environments = appData?.candidates.environmentOptions?.filter(env => env.id) ?? []
|
||||||
|
const deploymentRows = deployedRows(appData?.environmentDeployments.environmentDeployments)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-7 items-center gap-1 rounded-md px-2 system-xs-medium',
|
||||||
|
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-accent-text',
|
||||||
|
'hover:bg-components-button-secondary-bg-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('versions.deploy')}
|
||||||
|
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
{open && (
|
||||||
|
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
|
||||||
|
{environments.map((env) => {
|
||||||
|
const envId = env.id!
|
||||||
|
const row = deploymentRows.find(item => environmentId(item.environment) === envId)
|
||||||
|
const isCurrent = activeRelease(row)?.id === releaseId
|
||||||
|
const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false
|
||||||
|
const disabled = Boolean(env.disabled || isCurrent || isEnvironmentDeploying)
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={envId}
|
||||||
|
className="gap-2 px-3"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
if (disabled)
|
||||||
|
return
|
||||||
|
if (row) {
|
||||||
|
openRollbackModal({
|
||||||
|
appId,
|
||||||
|
environmentId: envId,
|
||||||
|
deploymentId: deploymentId(row),
|
||||||
|
targetReleaseId: releaseId,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openDeployDrawer({ appId, environmentId: envId, releaseId })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="system-sm-regular text-text-secondary">
|
||||||
|
{isEnvironmentDeploying
|
||||||
|
? t('versions.deployingTo', { name: environmentName(env) })
|
||||||
|
: isCurrent
|
||||||
|
? t('versions.currentOn', { name: environmentName(env) })
|
||||||
|
: row
|
||||||
|
? t('versions.promoteTo', { name: environmentName(env) })
|
||||||
|
: t('versions.deployTo', { name: environmentName(env) })}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { ReleaseDeployment, ReleaseDeploymentState } from './release-deployments'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
|
||||||
|
active: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
|
||||||
|
deploying: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
|
||||||
|
failed: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeployedToBadgeProps = {
|
||||||
|
item: ReleaseDeployment
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeployedToBadge: FC<DeployedToBadgeProps> = ({ item }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const statusLabel = t(`versions.deployedStatus.${item.state}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={(
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 system-xs-medium',
|
||||||
|
RELEASE_DEPLOYMENT_STYLES[item.state],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.state === 'deploying'
|
||||||
|
? <span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
|
||||||
|
: item.state === 'failed'
|
||||||
|
? <span className="i-ri-alert-line h-3.5 w-3.5" />
|
||||||
|
: <span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
||||||
|
{item.environmentName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TooltipContent>
|
||||||
|
{statusLabel}
|
||||||
|
{' · '}
|
||||||
|
{item.environmentName}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import type { DeployedToSummary, EnvironmentDeploymentRow, ReleaseHistoryRow } from '@/contract/console/deployments'
|
||||||
|
import {
|
||||||
|
activeRelease,
|
||||||
|
environmentId,
|
||||||
|
environmentName,
|
||||||
|
targetRelease,
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
export type ReleaseDeploymentState = 'active' | 'deploying' | 'failed'
|
||||||
|
|
||||||
|
export type ReleaseDeployment = {
|
||||||
|
environmentId: string
|
||||||
|
environmentName: string
|
||||||
|
state: ReleaseDeploymentState
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDeploymentState(status?: string): ReleaseDeploymentState {
|
||||||
|
const normalized = status?.toLowerCase() ?? ''
|
||||||
|
if (normalized.includes('deploying') || normalized.includes('pending'))
|
||||||
|
return 'deploying'
|
||||||
|
if (normalized.includes('fail') || normalized.includes('error'))
|
||||||
|
return 'failed'
|
||||||
|
return 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDeployedTo(item: DeployedToSummary): ReleaseDeployment | undefined {
|
||||||
|
if (!item.environmentId)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
environmentId: item.environmentId,
|
||||||
|
environmentName: item.environmentName || item.environmentId,
|
||||||
|
state: releaseDeploymentState(item.instanceStatus),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
|
||||||
|
return items.filter((item, index) => {
|
||||||
|
const key = `${item.environmentId}-${item.state}`
|
||||||
|
return items.findIndex(candidate => `${candidate.environmentId}-${candidate.state}` === key) === index
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReleaseDeployments(row: ReleaseHistoryRow, deploymentRows: EnvironmentDeploymentRow[]) {
|
||||||
|
const releaseId = row.release?.id
|
||||||
|
if (!releaseId)
|
||||||
|
return []
|
||||||
|
|
||||||
|
const historyItems = row.deployedTo?.map(fromDeployedTo).filter((item): item is ReleaseDeployment => !!item) ?? []
|
||||||
|
const runtimeItems = deploymentRows.flatMap((deployment) => {
|
||||||
|
const envId = environmentId(deployment.environment)
|
||||||
|
if (!envId)
|
||||||
|
return []
|
||||||
|
|
||||||
|
const items: ReleaseDeployment[] = []
|
||||||
|
if (activeRelease(deployment)?.id === releaseId) {
|
||||||
|
items.push({
|
||||||
|
environmentId: envId,
|
||||||
|
environmentName: environmentName(deployment.environment),
|
||||||
|
state: 'active',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (targetRelease(deployment)?.id === releaseId) {
|
||||||
|
items.push({
|
||||||
|
environmentId: envId,
|
||||||
|
environmentName: environmentName(deployment.environment),
|
||||||
|
state: 'deploying',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (deployment.instance?.lastError?.releaseId === releaseId) {
|
||||||
|
items.push({
|
||||||
|
environmentId: envId,
|
||||||
|
environmentName: environmentName(deployment.environment),
|
||||||
|
state: 'failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
return dedupeReleaseDeployments([...historyItems, ...runtimeItems])
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import type { AppInfo } from '../types'
|
import type { AppInfo } from '../types'
|
||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { deploymentAppDataQueryOptions } from '@/service/deployments'
|
import { deploymentAppDataQueryOptions } from '../data'
|
||||||
import { useDeploymentsStore } from '../store'
|
import { useDeploymentsStore } from '../store'
|
||||||
|
|
||||||
type UseDeploymentDataOptions = {
|
type UseDeploymentDataOptions = {
|
||||||
|
|||||||
86
web/features/deployments/list/environment-filter.tsx
Normal file
86
web/features/deployments/list/environment-filter.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@langgenius/dify-ui/dropdown-menu'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export type EnvironmentFilterOption = {
|
||||||
|
value: string
|
||||||
|
text: string
|
||||||
|
icon: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
disabledReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvironmentFilterProps = {
|
||||||
|
value: string
|
||||||
|
options: EnvironmentFilterOption[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ value, options, onChange }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const selectedOption = options.find(option => option.value === value) ?? options[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left select-none',
|
||||||
|
open && 'shadow-xs',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-px text-text-tertiary">
|
||||||
|
{selectedOption?.icon}
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[160px] min-w-0 truncate text-[13px] leading-[18px] text-text-secondary">
|
||||||
|
{selectedOption?.text}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-px">
|
||||||
|
<span className={cn('i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
{open && (
|
||||||
|
<DropdownMenuContent
|
||||||
|
placement="bottom-start"
|
||||||
|
sideOffset={4}
|
||||||
|
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
|
||||||
|
>
|
||||||
|
<div className="max-h-72 overflow-auto p-1">
|
||||||
|
{options.map(option => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (option.disabled)
|
||||||
|
return
|
||||||
|
onChange(option.value)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
title={option.disabled ? option.disabledReason : undefined}
|
||||||
|
aria-disabled={option.disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none',
|
||||||
|
option.disabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer hover:bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
|
||||||
|
<span className="grow truncate text-sm leading-5 text-text-tertiary">{option.text}</span>
|
||||||
|
{option.value === value && (
|
||||||
|
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-secondary" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,448 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { AppInfo } from '../types'
|
|
||||||
import type { AppDeploymentSummary } from '@/contract/console/deployments'
|
|
||||||
import type { DeploymentAppData } from '@/service/deployments'
|
|
||||||
import type { AppModeEnum } from '@/types/app'
|
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@langgenius/dify-ui/dropdown-menu'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
|
||||||
import { useDebounceFn } from 'ahooks'
|
import { useDebounceFn } from 'ahooks'
|
||||||
import { parseAsString, useQueryState } from 'nuqs'
|
import { parseAsString, useQueryState } from 'nuqs'
|
||||||
import * as React from 'react'
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 Input from '@/app/components/base/input'
|
||||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
|
||||||
import { useRouter } from '@/next/navigation'
|
|
||||||
import CreateInstanceModal from '../components/create-instance-modal'
|
import CreateInstanceModal from '../components/create-instance-modal'
|
||||||
import DeployDrawer from '../components/deploy-drawer'
|
import DeployDrawer from '../components/deploy-drawer'
|
||||||
import RollbackModal from '../components/rollback-modal'
|
import RollbackModal from '../components/rollback-modal'
|
||||||
import { useSourceApps } from '../hooks/use-source-apps'
|
import { useSourceApps } from '../hooks/use-source-apps'
|
||||||
import { useDeploymentsStore } from '../store'
|
import { useDeploymentsStore } from '../store'
|
||||||
import { deployedRows, deploymentStatus, environmentId, environmentName, releaseLabel } from '../utils'
|
import { environmentId, environmentName } from '../utils'
|
||||||
|
import { EnvironmentFilter } from './environment-filter'
|
||||||
type NewInstanceCardProps = {
|
import { InstanceCard } from './instance-card'
|
||||||
onOpen: () => void
|
import { NewInstanceCard } from './new-instance-card'
|
||||||
}
|
|
||||||
|
|
||||||
type NewInstanceActionProps = {
|
|
||||||
icon: string
|
|
||||||
label: string
|
|
||||||
disabled?: boolean
|
|
||||||
onClick?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const NewInstanceAction: FC<NewInstanceActionProps> = ({ icon, label, disabled, onClick }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={disabled ? undefined : onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
title={disabled ? t('newInstance.comingSoon') : undefined}
|
|
||||||
className={cn(
|
|
||||||
'mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-left text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
|
||||||
disabled
|
|
||||||
? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-text-tertiary'
|
|
||||||
: 'cursor-pointer',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span aria-hidden className={cn('mr-2 h-4 w-4 shrink-0', icon)} />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
||||||
{disabled && (
|
|
||||||
<span className="ml-2 shrink-0 rounded-md bg-state-base-hover px-1.5 system-2xs-medium text-text-tertiary">
|
|
||||||
{t('newInstance.comingSoon')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const NewInstanceCard: FC<NewInstanceCardProps> = ({ onOpen }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
return (
|
|
||||||
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg">
|
|
||||||
<div className="grow rounded-t-xl p-2">
|
|
||||||
<div className="px-6 pt-2 pb-1 text-xs leading-[18px] font-medium text-text-tertiary">
|
|
||||||
{t('newInstance.title')}
|
|
||||||
</div>
|
|
||||||
<NewInstanceAction
|
|
||||||
icon="i-ri-stack-line"
|
|
||||||
label={t('newInstance.fromStudio')}
|
|
||||||
onClick={onOpen}
|
|
||||||
/>
|
|
||||||
<NewInstanceAction
|
|
||||||
icon="i-ri-github-fill"
|
|
||||||
label={t('newInstance.fromGitHub')}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<NewInstanceAction
|
|
||||||
icon="i-ri-file-code-line"
|
|
||||||
label={t('newInstance.importDSL')}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type InstanceCardProps = {
|
|
||||||
app: AppInfo
|
|
||||||
appData?: DeploymentAppData
|
|
||||||
summary?: AppDeploymentSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
const InstanceCard: FC<InstanceCardProps> = ({ app, appData, summary }) => {
|
|
||||||
const { t } = useTranslation('deployments')
|
|
||||||
const router = useRouter()
|
|
||||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
|
||||||
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
|
|
||||||
|
|
||||||
const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`)
|
|
||||||
|
|
||||||
const handleMenuAction = (e: React.MouseEvent<HTMLElement>, action: () => void) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
setMenuOpen(false)
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
|
|
||||||
const deployments = useMemo(
|
|
||||||
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
|
|
||||||
[appData?.environmentDeployments.environmentDeployments],
|
|
||||||
)
|
|
||||||
const statusCount = (status: string) =>
|
|
||||||
summary?.statusCounts?.find(item => item.status === status)?.count ?? 0
|
|
||||||
const hasSummary = Boolean(summary)
|
|
||||||
const failedCount = hasSummary
|
|
||||||
? statusCount('failed') + statusCount('deploy_failed')
|
|
||||||
: deployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
|
|
||||||
const deployingCount = hasSummary
|
|
||||||
? statusCount('deploying')
|
|
||||||
: deployments.filter(row => deploymentStatus(row) === 'deploying').length
|
|
||||||
const readyCount = hasSummary
|
|
||||||
? statusCount('ready')
|
|
||||||
: deployments.filter(row => deploymentStatus(row) === 'ready').length
|
|
||||||
const envCount = hasSummary
|
|
||||||
? (summary?.deployed ? failedCount + deployingCount + readyCount : 0)
|
|
||||||
: deployments.length
|
|
||||||
|
|
||||||
const lastDeployedAt = useMemo(() => {
|
|
||||||
if (summary?.lastDeployedAt)
|
|
||||||
return new Date(summary.lastDeployedAt).getTime()
|
|
||||||
if (deployments.length === 0)
|
|
||||||
return null
|
|
||||||
return deployments.reduce((latest, row) => {
|
|
||||||
const t = new Date(row.instance?.lastDeployedAt || row.instance?.lastReadyAt || '').getTime()
|
|
||||||
return t > latest ? t : latest
|
|
||||||
}, 0)
|
|
||||||
}, [deployments, summary?.lastDeployedAt])
|
|
||||||
|
|
||||||
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
|
|
||||||
? 'none'
|
|
||||||
: failedCount > 0
|
|
||||||
? 'failed'
|
|
||||||
: deployingCount > 0
|
|
||||||
? 'deploying'
|
|
||||||
: 'ready'
|
|
||||||
|
|
||||||
const primaryText = primaryStatus === 'none'
|
|
||||||
? t('card.notDeployed')
|
|
||||||
: primaryStatus === 'failed'
|
|
||||||
? t('card.failed', { count: failedCount })
|
|
||||||
: primaryStatus === 'deploying'
|
|
||||||
? t('card.deploying', { count: deployingCount })
|
|
||||||
: t('card.ready', { count: readyCount })
|
|
||||||
|
|
||||||
const secondaryParts: string[] = []
|
|
||||||
if (primaryStatus === 'failed' && deployingCount > 0)
|
|
||||||
secondaryParts.push(t('card.deploying', { count: deployingCount }))
|
|
||||||
if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0)
|
|
||||||
secondaryParts.push(t('card.ready', { count: readyCount }))
|
|
||||||
|
|
||||||
const statusLabel = (status: ReturnType<typeof deploymentStatus>) => {
|
|
||||||
if (status === 'deploy_failed')
|
|
||||||
return t('status.deployFailed')
|
|
||||||
return t(`status.${status}`)
|
|
||||||
}
|
|
||||||
const statusSummaryLabel = (status?: string) => {
|
|
||||||
if (status === 'failed' || status === 'deploy_failed')
|
|
||||||
return t('status.deployFailed')
|
|
||||||
if (status === 'deploying')
|
|
||||||
return t('status.deploying')
|
|
||||||
if (status === 'ready')
|
|
||||||
return t('status.ready')
|
|
||||||
return status || 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusSummaryTooltip = summary?.statusCounts?.filter(item => item.count && item.status !== 'undeployed') ?? []
|
|
||||||
const statusTooltip = primaryStatus === 'none'
|
|
||||||
? t('card.tooltip.notDeployed')
|
|
||||||
: deployments.length > 0
|
|
||||||
? (
|
|
||||||
<div className="flex min-w-[220px] flex-col gap-1">
|
|
||||||
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
|
||||||
{deployments.map((deployment) => {
|
|
||||||
const status = deploymentStatus(deployment)
|
|
||||||
return (
|
|
||||||
<div key={environmentId(deployment.environment)} className="flex min-w-0 items-center justify-between gap-3">
|
|
||||||
<span className="min-w-0 truncate text-text-tertiary">
|
|
||||||
{environmentName(deployment.environment)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-text-secondary">
|
|
||||||
{statusLabel(status)}
|
|
||||||
{' · '}
|
|
||||||
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="flex min-w-[180px] flex-col gap-1">
|
|
||||||
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
|
||||||
{statusSummaryTooltip.map(item => (
|
|
||||||
<div key={item.status} className="flex justify-between gap-3">
|
|
||||||
<span className="text-text-tertiary">{statusSummaryLabel(item.status)}</span>
|
|
||||||
<span className="text-text-secondary">{item.count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const healthPillClass = primaryStatus === 'none'
|
|
||||||
? 'text-text-tertiary bg-background-section-burn'
|
|
||||||
: primaryStatus === 'failed'
|
|
||||||
? 'text-util-colors-red-red-700 bg-util-colors-red-red-50'
|
|
||||||
: primaryStatus === 'deploying'
|
|
||||||
? 'text-util-colors-warning-warning-700 bg-util-colors-warning-warning-50'
|
|
||||||
: 'text-util-colors-green-green-700 bg-util-colors-green-green-50'
|
|
||||||
|
|
||||||
const healthDotClass = primaryStatus === 'none'
|
|
||||||
? 'bg-text-quaternary'
|
|
||||||
: primaryStatus === 'failed'
|
|
||||||
? 'bg-util-colors-red-red-500'
|
|
||||||
: primaryStatus === 'deploying'
|
|
||||||
? 'bg-util-colors-warning-warning-500 animate-pulse'
|
|
||||||
: 'bg-util-colors-green-green-500'
|
|
||||||
|
|
||||||
const appModeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
navigateToDetail()
|
|
||||||
}}
|
|
||||||
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<AppIcon
|
|
||||||
size="large"
|
|
||||||
iconType={app.iconType}
|
|
||||||
icon={app.icon}
|
|
||||||
background={app.iconBackground}
|
|
||||||
imageUrl={app.iconUrl}
|
|
||||||
/>
|
|
||||||
<AppTypeIcon
|
|
||||||
type={app.mode as unknown as AppModeEnum}
|
|
||||||
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
|
|
||||||
className="h-3 w-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-0 grow py-px">
|
|
||||||
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
|
|
||||||
<div className="truncate" title={app.name}>{app.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-[10px] leading-[18px] font-medium text-text-tertiary" title={appModeLabel}>
|
|
||||||
{appModeLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex grow flex-col gap-2 px-[14px]">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={(
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 system-xs-medium',
|
|
||||||
healthPillClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn('h-1.5 w-1.5 rounded-full', healthDotClass)} />
|
|
||||||
{primaryText}
|
|
||||||
</span>
|
|
||||||
{secondaryParts.length > 0 && (
|
|
||||||
<span className="truncate system-xs-regular text-text-tertiary">
|
|
||||||
{secondaryParts.join(' · ')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<TooltipContent>{statusTooltip}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
|
|
||||||
<span aria-hidden className="i-ri-apps-2-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
|
|
||||||
<span className="truncate" title={app.name}>
|
|
||||||
{t('card.fromApp', { name: app.name })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
|
|
||||||
<div className="mr-[41px] flex min-w-0 grow items-center gap-1.5 system-xs-regular text-text-tertiary">
|
|
||||||
<span aria-hidden className="i-ri-time-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
|
|
||||||
<span className="truncate">
|
|
||||||
{lastDeployedAt
|
|
||||||
? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) })
|
|
||||||
: t('card.neverDeployed')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
|
|
||||||
menuOpen
|
|
||||||
? 'pointer-events-auto opacity-100'
|
|
||||||
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
aria-label={t('card.moreActions')}
|
|
||||||
className={cn(
|
|
||||||
menuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover',
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
{menuOpen && (
|
|
||||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[216px]">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2 px-3"
|
|
||||||
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))}
|
|
||||||
>
|
|
||||||
<span className="system-sm-regular text-text-secondary">{t('card.menu.deploy')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2 px-3"
|
|
||||||
onClick={e => handleMenuAction(e, navigateToDetail)}
|
|
||||||
>
|
|
||||||
<span className="system-sm-regular text-text-secondary">{t('card.menu.viewDetail')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
aria-disabled
|
|
||||||
title={t('card.menu.deleteDisabled')}
|
|
||||||
className="cursor-not-allowed gap-2 px-3 opacity-50"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="system-sm-regular text-text-destructive">{t('card.menu.delete')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
)}
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnvironmentFilterOption = {
|
|
||||||
value: string
|
|
||||||
text: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
disabled?: boolean
|
|
||||||
disabledReason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnvironmentFilterProps = {
|
|
||||||
value: string
|
|
||||||
options: EnvironmentFilterOption[]
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ value, options, onChange }) => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const selectedOption = options.find(option => option.value === value) ?? options[0]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left select-none',
|
|
||||||
open && 'shadow-xs',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-px text-text-tertiary">
|
|
||||||
{selectedOption?.icon}
|
|
||||||
</div>
|
|
||||||
<div className="max-w-[160px] min-w-0 truncate text-[13px] leading-[18px] text-text-secondary">
|
|
||||||
{selectedOption?.text}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 p-px">
|
|
||||||
<span className={cn('i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
{open && (
|
|
||||||
<DropdownMenuContent
|
|
||||||
placement="bottom-start"
|
|
||||||
sideOffset={4}
|
|
||||||
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
|
|
||||||
>
|
|
||||||
<div className="max-h-72 overflow-auto p-1">
|
|
||||||
{options.map(option => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => {
|
|
||||||
if (option.disabled)
|
|
||||||
return
|
|
||||||
onChange(option.value)
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
title={option.disabled ? option.disabledReason : undefined}
|
|
||||||
aria-disabled={option.disabled}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none',
|
|
||||||
option.disabled
|
|
||||||
? 'cursor-not-allowed opacity-50'
|
|
||||||
: 'cursor-pointer hover:bg-state-base-hover',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
|
|
||||||
<span className="grow truncate text-sm leading-5 text-text-tertiary">{option.text}</span>
|
|
||||||
{option.value === value && (
|
|
||||||
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-secondary" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
)}
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeploymentsMain: FC = () => {
|
const DeploymentsMain: FC = () => {
|
||||||
const { t } = useTranslation('deployments')
|
const { t } = useTranslation('deployments')
|
||||||
@ -547,16 +119,14 @@ const DeploymentsMain: FC = () => {
|
|||||||
</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">
|
<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} />
|
<NewInstanceCard onOpen={openCreateInstanceModal} />
|
||||||
{visibleInstances.map((app) => {
|
{visibleInstances.map(app => (
|
||||||
return (
|
<InstanceCard
|
||||||
<InstanceCard
|
key={app.id}
|
||||||
key={app.id}
|
app={app}
|
||||||
app={app}
|
appData={appData[app.id]}
|
||||||
appData={appData[app.id]}
|
summary={summaries[app.id]}
|
||||||
summary={summaries[app.id]}
|
/>
|
||||||
/>
|
))}
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-4" />
|
<div className="py-4" />
|
||||||
|
|||||||
297
web/features/deployments/list/instance-card.tsx
Normal file
297
web/features/deployments/list/instance-card.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC, MouseEvent } from 'react'
|
||||||
|
import type { DeploymentAppData } from '../data'
|
||||||
|
import type { AppInfo } from '../types'
|
||||||
|
import type { AppDeploymentSummary } from '@/contract/console/deployments'
|
||||||
|
import type { AppModeEnum } from '@/types/app'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@langgenius/dify-ui/dropdown-menu'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||||
|
import AppIcon from '@/app/components/base/app-icon'
|
||||||
|
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||||
|
import { useRouter } from '@/next/navigation'
|
||||||
|
import { useDeploymentsStore } from '../store'
|
||||||
|
import { deployedRows, deploymentStatus, environmentId, environmentName, releaseLabel } from '../utils'
|
||||||
|
|
||||||
|
type InstanceCardProps = {
|
||||||
|
app: AppInfo
|
||||||
|
appData?: DeploymentAppData
|
||||||
|
summary?: AppDeploymentSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InstanceCard: FC<InstanceCardProps> = ({ app, appData, summary }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
const router = useRouter()
|
||||||
|
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
|
||||||
|
|
||||||
|
const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`)
|
||||||
|
|
||||||
|
const handleMenuAction = (e: MouseEvent<HTMLElement>, action: () => void) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
setMenuOpen(false)
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployments = useMemo(
|
||||||
|
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
|
||||||
|
[appData?.environmentDeployments.environmentDeployments],
|
||||||
|
)
|
||||||
|
const statusCount = (status: string) =>
|
||||||
|
summary?.statusCounts?.find(item => item.status === status)?.count ?? 0
|
||||||
|
const hasSummary = Boolean(summary)
|
||||||
|
const failedCount = hasSummary
|
||||||
|
? statusCount('failed') + statusCount('deploy_failed')
|
||||||
|
: deployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
|
||||||
|
const deployingCount = hasSummary
|
||||||
|
? statusCount('deploying')
|
||||||
|
: deployments.filter(row => deploymentStatus(row) === 'deploying').length
|
||||||
|
const readyCount = hasSummary
|
||||||
|
? statusCount('ready')
|
||||||
|
: deployments.filter(row => deploymentStatus(row) === 'ready').length
|
||||||
|
const envCount = hasSummary
|
||||||
|
? (summary?.deployed ? failedCount + deployingCount + readyCount : 0)
|
||||||
|
: deployments.length
|
||||||
|
|
||||||
|
const lastDeployedAt = useMemo(() => {
|
||||||
|
if (summary?.lastDeployedAt)
|
||||||
|
return new Date(summary.lastDeployedAt).getTime()
|
||||||
|
if (deployments.length === 0)
|
||||||
|
return null
|
||||||
|
return deployments.reduce((latest, row) => {
|
||||||
|
const t = new Date(row.instance?.lastDeployedAt || row.instance?.lastReadyAt || '').getTime()
|
||||||
|
return t > latest ? t : latest
|
||||||
|
}, 0)
|
||||||
|
}, [deployments, summary?.lastDeployedAt])
|
||||||
|
|
||||||
|
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
|
||||||
|
? 'none'
|
||||||
|
: failedCount > 0
|
||||||
|
? 'failed'
|
||||||
|
: deployingCount > 0
|
||||||
|
? 'deploying'
|
||||||
|
: 'ready'
|
||||||
|
|
||||||
|
const primaryText = primaryStatus === 'none'
|
||||||
|
? t('card.notDeployed')
|
||||||
|
: primaryStatus === 'failed'
|
||||||
|
? t('card.failed', { count: failedCount })
|
||||||
|
: primaryStatus === 'deploying'
|
||||||
|
? t('card.deploying', { count: deployingCount })
|
||||||
|
: t('card.ready', { count: readyCount })
|
||||||
|
|
||||||
|
const secondaryParts: string[] = []
|
||||||
|
if (primaryStatus === 'failed' && deployingCount > 0)
|
||||||
|
secondaryParts.push(t('card.deploying', { count: deployingCount }))
|
||||||
|
if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0)
|
||||||
|
secondaryParts.push(t('card.ready', { count: readyCount }))
|
||||||
|
|
||||||
|
const statusLabel = (status: ReturnType<typeof deploymentStatus>) => {
|
||||||
|
if (status === 'deploy_failed')
|
||||||
|
return t('status.deployFailed')
|
||||||
|
return t(`status.${status}`)
|
||||||
|
}
|
||||||
|
const statusSummaryLabel = (status?: string) => {
|
||||||
|
if (status === 'failed' || status === 'deploy_failed')
|
||||||
|
return t('status.deployFailed')
|
||||||
|
if (status === 'deploying')
|
||||||
|
return t('status.deploying')
|
||||||
|
if (status === 'ready')
|
||||||
|
return t('status.ready')
|
||||||
|
return status || 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusSummaryTooltip = summary?.statusCounts?.filter(item => item.count && item.status !== 'undeployed') ?? []
|
||||||
|
const statusTooltip = primaryStatus === 'none'
|
||||||
|
? t('card.tooltip.notDeployed')
|
||||||
|
: deployments.length > 0
|
||||||
|
? (
|
||||||
|
<div className="flex min-w-[220px] flex-col gap-1">
|
||||||
|
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
||||||
|
{deployments.map((deployment) => {
|
||||||
|
const status = deploymentStatus(deployment)
|
||||||
|
return (
|
||||||
|
<div key={environmentId(deployment.environment)} className="flex min-w-0 items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 truncate text-text-tertiary">
|
||||||
|
{environmentName(deployment.environment)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-text-secondary">
|
||||||
|
{statusLabel(status)}
|
||||||
|
{' · '}
|
||||||
|
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="flex min-w-[180px] flex-col gap-1">
|
||||||
|
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
||||||
|
{statusSummaryTooltip.map(item => (
|
||||||
|
<div key={item.status} className="flex justify-between gap-3">
|
||||||
|
<span className="text-text-tertiary">{statusSummaryLabel(item.status)}</span>
|
||||||
|
<span className="text-text-secondary">{item.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const healthPillClass = primaryStatus === 'none'
|
||||||
|
? 'text-text-tertiary bg-background-section-burn'
|
||||||
|
: primaryStatus === 'failed'
|
||||||
|
? 'text-util-colors-red-red-700 bg-util-colors-red-red-50'
|
||||||
|
: primaryStatus === 'deploying'
|
||||||
|
? 'text-util-colors-warning-warning-700 bg-util-colors-warning-warning-50'
|
||||||
|
: 'text-util-colors-green-green-700 bg-util-colors-green-green-50'
|
||||||
|
|
||||||
|
const healthDotClass = primaryStatus === 'none'
|
||||||
|
? 'bg-text-quaternary'
|
||||||
|
: primaryStatus === 'failed'
|
||||||
|
? 'bg-util-colors-red-red-500'
|
||||||
|
: primaryStatus === 'deploying'
|
||||||
|
? 'bg-util-colors-warning-warning-500 animate-pulse'
|
||||||
|
: 'bg-util-colors-green-green-500'
|
||||||
|
|
||||||
|
const appModeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
navigateToDetail()
|
||||||
|
}}
|
||||||
|
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<AppIcon
|
||||||
|
size="large"
|
||||||
|
iconType={app.iconType}
|
||||||
|
icon={app.icon}
|
||||||
|
background={app.iconBackground}
|
||||||
|
imageUrl={app.iconUrl}
|
||||||
|
/>
|
||||||
|
<AppTypeIcon
|
||||||
|
type={app.mode as unknown as AppModeEnum}
|
||||||
|
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-0 grow py-px">
|
||||||
|
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
|
||||||
|
<div className="truncate" title={app.name}>{app.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[10px] leading-[18px] font-medium text-text-tertiary" title={appModeLabel}>
|
||||||
|
{appModeLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex grow flex-col gap-2 px-[14px]">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={(
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 system-xs-medium',
|
||||||
|
healthPillClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', healthDotClass)} />
|
||||||
|
{primaryText}
|
||||||
|
</span>
|
||||||
|
{secondaryParts.length > 0 && (
|
||||||
|
<span className="truncate system-xs-regular text-text-tertiary">
|
||||||
|
{secondaryParts.join(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TooltipContent>{statusTooltip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
|
||||||
|
<span aria-hidden className="i-ri-apps-2-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
|
||||||
|
<span className="truncate" title={app.name}>
|
||||||
|
{t('card.fromApp', { name: app.name })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
|
||||||
|
<div className="mr-[41px] flex min-w-0 grow items-center gap-1.5 system-xs-regular text-text-tertiary">
|
||||||
|
<span aria-hidden className="i-ri-time-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
|
||||||
|
<span className="truncate">
|
||||||
|
{lastDeployedAt
|
||||||
|
? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) })
|
||||||
|
: t('card.neverDeployed')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
|
||||||
|
menuOpen
|
||||||
|
? 'pointer-events-auto opacity-100'
|
||||||
|
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
aria-label={t('card.moreActions')}
|
||||||
|
className={cn(
|
||||||
|
menuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
{menuOpen && (
|
||||||
|
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[216px]">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2 px-3"
|
||||||
|
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))}
|
||||||
|
>
|
||||||
|
<span className="system-sm-regular text-text-secondary">{t('card.menu.deploy')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2 px-3"
|
||||||
|
onClick={e => handleMenuAction(e, navigateToDetail)}
|
||||||
|
>
|
||||||
|
<span className="system-sm-regular text-text-secondary">{t('card.menu.viewDetail')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
aria-disabled
|
||||||
|
title={t('card.menu.deleteDisabled')}
|
||||||
|
className="cursor-not-allowed gap-2 px-3 opacity-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="system-sm-regular text-text-destructive">{t('card.menu.delete')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
web/features/deployments/list/new-instance-card.tsx
Normal file
71
web/features/deployments/list/new-instance-card.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type NewInstanceCardProps = {
|
||||||
|
onOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewInstanceActionProps = {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewInstanceAction: FC<NewInstanceActionProps> = ({ icon, label, disabled, onClick }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={disabled ? t('newInstance.comingSoon') : undefined}
|
||||||
|
className={cn(
|
||||||
|
'mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-left text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||||
|
disabled
|
||||||
|
? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-text-tertiary'
|
||||||
|
: 'cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden className={cn('mr-2 h-4 w-4 shrink-0', icon)} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{disabled && (
|
||||||
|
<span className="ml-2 shrink-0 rounded-md bg-state-base-hover px-1.5 system-2xs-medium text-text-tertiary">
|
||||||
|
{t('newInstance.comingSoon')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewInstanceCard: FC<NewInstanceCardProps> = ({ onOpen }) => {
|
||||||
|
const { t } = useTranslation('deployments')
|
||||||
|
return (
|
||||||
|
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg">
|
||||||
|
<div className="grow rounded-t-xl p-2">
|
||||||
|
<div className="px-6 pt-2 pb-1 text-xs leading-[18px] font-medium text-text-tertiary">
|
||||||
|
{t('newInstance.title')}
|
||||||
|
</div>
|
||||||
|
<NewInstanceAction
|
||||||
|
icon="i-ri-stack-line"
|
||||||
|
label={t('newInstance.fromStudio')}
|
||||||
|
onClick={onOpen}
|
||||||
|
/>
|
||||||
|
<NewInstanceAction
|
||||||
|
icon="i-ri-github-fill"
|
||||||
|
label={t('newInstance.fromGitHub')}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<NewInstanceAction
|
||||||
|
icon="i-ri-file-code-line"
|
||||||
|
label={t('newInstance.importDSL')}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
|
import type { DeploymentAppData } from './data'
|
||||||
import type { AppInfo } from './types'
|
import type { AppInfo } from './types'
|
||||||
import type { AccessSubject, APIToken, BindingsProto } from '@/contract/console/deployments'
|
import type { AccessSubject, APIToken, BindingsProto } from '@/contract/console/deployments'
|
||||||
import type { DeploymentAppData } from '@/service/deployments'
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import {
|
import {
|
||||||
cancelDeployment,
|
cancelDeployment,
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
rollbackEnvironment,
|
rollbackEnvironment,
|
||||||
undeployEnvironment,
|
undeployEnvironment,
|
||||||
updateEnvironmentAccessPolicy,
|
updateEnvironmentAccessPolicy,
|
||||||
} from '@/service/deployments'
|
} from './data'
|
||||||
|
|
||||||
export type StartDeployParams = {
|
export type StartDeployParams = {
|
||||||
appId: string
|
appId: string
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user