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