From 1aea4e00a4f83554892137cac479a3ab09468ac9 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:25:41 +0800 Subject: [PATCH] tweaks --- .../deployments/components/deploy-drawer.tsx | 433 +-------- .../components/deploy-drawer/bindings.ts | 114 +++ .../components/deploy-drawer/form.tsx | 246 +++++ .../components/deploy-drawer/select.tsx | 96 ++ .../deployments/data.ts} | 2 +- .../deployments/detail/access-tab.tsx | 901 +----------------- .../detail/access-tab/api-keys.tsx | 118 +++ .../detail/access-tab/channels-section.tsx | 134 +++ .../deployments/detail/access-tab/common.tsx | 106 +++ .../access-tab/developer-api-section.tsx | 119 +++ .../detail/access-tab/permissions-section.tsx | 61 ++ .../detail/access-tab/permissions.tsx | 445 +++++++++ .../deployments/detail/access-tab/url.ts | 10 + .../deployments/detail/deploy-tab.tsx | 156 +-- .../detail/deploy-tab/deployment-panel.tsx | 134 +++ .../deploy-tab/deployment-status-summary.tsx | 45 + .../deployments/detail/deployment-sidebar.tsx | 200 ++++ web/features/deployments/detail/index.tsx | 200 +--- .../deployments/detail/versions-tab.tsx | 206 +--- .../versions-tab/deploy-release-menu.tsx | 95 ++ .../detail/versions-tab/deployed-to-badge.tsx | 49 + .../versions-tab/release-deployments.ts | 81 ++ .../deployments/hooks/use-deployment-data.ts | 2 +- .../deployments/list/environment-filter.tsx | 86 ++ web/features/deployments/list/index.tsx | 456 +-------- .../deployments/list/instance-card.tsx | 297 ++++++ .../deployments/list/new-instance-card.tsx | 71 ++ web/features/deployments/store.ts | 4 +- 28 files changed, 2572 insertions(+), 2295 deletions(-) create mode 100644 web/features/deployments/components/deploy-drawer/bindings.ts create mode 100644 web/features/deployments/components/deploy-drawer/form.tsx create mode 100644 web/features/deployments/components/deploy-drawer/select.tsx rename web/{service/deployments.ts => features/deployments/data.ts} (99%) create mode 100644 web/features/deployments/detail/access-tab/api-keys.tsx create mode 100644 web/features/deployments/detail/access-tab/channels-section.tsx create mode 100644 web/features/deployments/detail/access-tab/common.tsx create mode 100644 web/features/deployments/detail/access-tab/developer-api-section.tsx create mode 100644 web/features/deployments/detail/access-tab/permissions-section.tsx create mode 100644 web/features/deployments/detail/access-tab/permissions.tsx create mode 100644 web/features/deployments/detail/access-tab/url.ts create mode 100644 web/features/deployments/detail/deploy-tab/deployment-panel.tsx create mode 100644 web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx create mode 100644 web/features/deployments/detail/deployment-sidebar.tsx create mode 100644 web/features/deployments/detail/versions-tab/deploy-release-menu.tsx create mode 100644 web/features/deployments/detail/versions-tab/deployed-to-badge.tsx create mode 100644 web/features/deployments/detail/versions-tab/release-deployments.ts create mode 100644 web/features/deployments/list/environment-filter.tsx create mode 100644 web/features/deployments/list/instance-card.tsx create mode 100644 web/features/deployments/list/new-instance-card.tsx diff --git a/web/features/deployments/components/deploy-drawer.tsx b/web/features/deployments/components/deploy-drawer.tsx index dacc255d44..e95806b1b8 100644 --- a/web/features/deployments/components/deploy-drawer.tsx +++ b/web/features/deployments/components/deploy-drawer.tsx @@ -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, item: CredentialRequirement) { - return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || '' -} - -function envVarValue(values: Record, item: EnvVarRequirement) { - return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || '' -} - -type FieldProps = { - label: string - hint?: string - children: React.ReactNode -} - -const Field: FC = ({ label, hint, children }) => ( -
-
-
{label}
- {hint && {hint}} -
- {children} -
-) - -type SelectOption = { value: string, label: string } - -type SelectProps = { - value: string - onChange: (value: string) => void - options: SelectOption[] - placeholder?: string -} - -const DeploymentSelect: FC = ({ value, onChange, options, placeholder }) => { - const { t } = useTranslation('deployments') - const selectedOption = useMemo( - () => options.find(option => option.value === value), - [options, value], - ) - - return ( - - ) -} - -type LabeledSelectProps = SelectProps & { label: string } - -const LabeledSelect: FC = ({ label, ...rest }) => ( -
- {label} -
- -
-
-) - -type EnvironmentRowProps = { env: EnvironmentOption } - -const EnvironmentRow: FC = ({ env }) => ( -
-
- {environmentName(env)} - - -
- {env.type ?? 'env'} -
-) - -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 = ({ - 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( - () => 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('') - const [modelCredentials, setModelCredentials] = useState>({}) - const [pluginCredentials, setPluginCredentials] = useState>({}) - const [envValues, setEnvValues] = useState>({}) - - 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 ( -
-
- - {isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')} - - - {isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')} - -
- - - {isPromote && presetRelease - ? ( -
-
-
- {releaseLabel(presetRelease)} - · - {releaseCommit(presetRelease)} - {presetRelease.description && ( - <> - · - {presetRelease.description} - - )} -
- {presetRelease.createdAt} -
- - {t('deployDrawer.existingReleaseHint')} - -
- ) - : ( -
- setReleaseNote(e.target.value)} - placeholder={t('deployDrawer.notePlaceholder')} - maxLength={80} - /> - - {t('deployDrawer.newReleaseHint')} - -
- )} -
- - - {lockedEnv - ? - : ( - 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')} - /> - )} - - - {(required.model.length > 0 || required.plugin.length > 0) && ( -
-
{t('deployDrawer.runtimeCredentials')}
- {required.model.length > 0 && ( - -
- {required.model.map((item) => { - return ( - setModelCredentials(prev => ({ ...prev, [item.slot]: v }))} - options={item.options.map(option => ({ - value: option.id, - label: option.label, - }))} - placeholder={t('deployDrawer.selectProviderKey', { provider: item.label })} - /> - ) - })} -
-
- )} - - {required.plugin.length > 0 && ( - -
- {required.plugin.map((item) => { - return ( - setPluginCredentials(prev => ({ ...prev, [item.slot]: v }))} - options={item.options.map(option => ({ value: option.id, label: option.label }))} - placeholder={t('deployDrawer.selectProviderCred', { provider: item.label })} - /> - ) - })} -
-
- )} -
- )} - - {required.envVars.length > 0 && ( - -
- {required.envVars.map(v => ( -
- {v.label} -
- setEnvValues(prev => ({ ...prev, [v.key]: next }))} - options={v.options.map(option => ({ value: option.id, label: option.label }))} - placeholder={t('deployDrawer.defaultSelect')} - /> -
-
- ))} -
-
- )} - -
- - -
-
- ) -} +import { DeployForm } from './deploy-drawer/form' const DeployDrawer: FC = () => { const { t } = useTranslation('deployments') diff --git a/web/features/deployments/components/deploy-drawer/bindings.ts b/web/features/deployments/components/deploy-drawer/bindings.ts new file mode 100644 index 0000000000..e6f0761cf3 --- /dev/null +++ b/web/features/deployments/components/deploy-drawer/bindings.ts @@ -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, item: CredentialRequirement) { + return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || '' +} + +export function envVarValue(values: Record, item: EnvVarRequirement) { + return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || '' +} + +export function deploymentBindings( + required: RequiredBindings, + modelCredentials: Record, + pluginCredentials: Record, + envValues: Record, +): 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), + })), + } +} diff --git a/web/features/deployments/components/deploy-drawer/form.tsx b/web/features/deployments/components/deploy-drawer/form.tsx new file mode 100644 index 0000000000..160aecb655 --- /dev/null +++ b/web/features/deployments/components/deploy-drawer/form.tsx @@ -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 = ({ + 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( + () => 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('') + const [modelCredentials, setModelCredentials] = useState>({}) + const [pluginCredentials, setPluginCredentials] = useState>({}) + const [envValues, setEnvValues] = useState>({}) + + 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 ( +
+
+ + {isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')} + + + {isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')} + +
+ + + {isPromote && presetRelease + ? ( +
+
+
+ {releaseLabel(presetRelease)} + · + {releaseCommit(presetRelease)} + {presetRelease.description && ( + <> + · + {presetRelease.description} + + )} +
+ {presetRelease.createdAt} +
+ + {t('deployDrawer.existingReleaseHint')} + +
+ ) + : ( +
+ setReleaseNote(e.target.value)} + placeholder={t('deployDrawer.notePlaceholder')} + maxLength={80} + /> + + {t('deployDrawer.newReleaseHint')} + +
+ )} +
+ + + {lockedEnv + ? + : ( + 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')} + /> + )} + + + {(required.model.length > 0 || required.plugin.length > 0) && ( +
+
{t('deployDrawer.runtimeCredentials')}
+ {required.model.length > 0 && ( + +
+ {required.model.map(item => ( + setModelCredentials(prev => ({ ...prev, [item.slot]: v }))} + options={item.options.map(option => ({ + value: option.id, + label: option.label, + }))} + placeholder={t('deployDrawer.selectProviderKey', { provider: item.label })} + /> + ))} +
+
+ )} + + {required.plugin.length > 0 && ( + +
+ {required.plugin.map(item => ( + setPluginCredentials(prev => ({ ...prev, [item.slot]: v }))} + options={item.options.map(option => ({ value: option.id, label: option.label }))} + placeholder={t('deployDrawer.selectProviderCred', { provider: item.label })} + /> + ))} +
+
+ )} +
+ )} + + {required.envVars.length > 0 && ( + +
+ {required.envVars.map(v => ( +
+ {v.label} +
+ setEnvValues(prev => ({ ...prev, [v.key]: next }))} + options={v.options.map(option => ({ value: option.id, label: option.label }))} + placeholder={t('deployDrawer.defaultSelect')} + /> +
+
+ ))} +
+
+ )} + +
+ + +
+
+ ) +} diff --git a/web/features/deployments/components/deploy-drawer/select.tsx b/web/features/deployments/components/deploy-drawer/select.tsx new file mode 100644 index 0000000000..2abc834c73 --- /dev/null +++ b/web/features/deployments/components/deploy-drawer/select.tsx @@ -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 = ({ label, hint, children }) => ( +
+
+
{label}
+ {hint && {hint}} +
+ {children} +
+) + +type SelectOption = { value: string, label: string } + +type SelectProps = { + value: string + onChange: (value: string) => void + options: SelectOption[] + placeholder?: string +} + +export const DeploymentSelect: FC = ({ value, onChange, options, placeholder }) => { + const { t } = useTranslation('deployments') + const selectedOption = useMemo( + () => options.find(option => option.value === value), + [options, value], + ) + + return ( + + ) +} + +type LabeledSelectProps = SelectProps & { label: string } + +export const LabeledSelect: FC = ({ label, ...rest }) => ( +
+ {label} +
+ +
+
+) + +type EnvironmentRowProps = { env: EnvironmentOption } + +export const EnvironmentRow: FC = ({ env }) => ( +
+
+ {environmentName(env)} + + +
+ {env.type ?? 'env'} +
+) diff --git a/web/service/deployments.ts b/web/features/deployments/data.ts similarity index 99% rename from web/service/deployments.ts rename to web/features/deployments/data.ts index 4ce05de553..2e06e2a698 100644 --- a/web/service/deployments.ts +++ b/web/features/deployments/data.ts @@ -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 diff --git a/web/features/deployments/detail/access-tab.tsx b/web/features/deployments/detail/access-tab.tsx index 2af7f2dde1..591407de33 100644 --- a/web/features/deployments/detail/access-tab.tsx +++ b/web/features/deployments/detail/access-tab.tsx @@ -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 = ({ title, description, action, children }) => ( -
-
-
-
{title}
- {description && ( -

{description}

- )} -
- {action} -
- {children} -
-) - -type CopyPillProps = { - label: string - value: string - prefix?: ReactNode - className?: string -} - -const CopyPill: FC = ({ 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 ( -
-
- {label} -
- {prefix} -
- {value} -
-
- -
- ) -} - -type ApiKeyRowProps = { - apiKey: DeveloperAPIKeySummary - onRevoke: () => void -} - -const ApiKeyRow: FC = ({ 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 ( -
-
- {apiKey.name || apiKey.id} - - {t('access.api.envPrefix', { env: apiKey.environmentName || apiKey.environmentId })} - -
-
-
- {displayValue} -
- - -
-
- ) -} - -const permissionIcon: Record = { - 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 = ({ value, disabled, onChange }) => { - const { t } = useTranslation('deployments') - const icon = permissionIcon[value] - const label = t(`access.permission.${value}`) - - return ( - - - - {label} - - - - {permissionOrder.map((kind) => { - const itemIcon = permissionIcon[kind] - const isSelected = kind === value - return ( - onChange(kind)} - className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2" - > - -
-
- - {t(`access.permission.${kind}`)} - -
- - {t(`access.permission.${kind}Desc`)} - -
- {isSelected && ( - - )} -
- ) - })} -
-
- ) -} - -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) { - 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 = ({ subject, disabled, onRemove }) => { - const { t } = useTranslation('deployments') - const isGroup = subject.subjectType === 'group' - - return ( -
- - {subject.name || subject.id} - {isGroup && subject.memberCount != null && ( - {subject.memberCount} - )} - -
- ) -} - -type SubjectPickerProps = { - appId: string - disabled?: boolean - selectedSubjects: SelectableAccessSubject[] - onChange: (subjects: SelectableAccessSubject[]) => void -} - -const SubjectPicker: FC = ({ - 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 ( - - - - {t('access.members.pickPlaceholder')} - - )} - /> - {open && ( - -
-
-
- - 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" - /> -
-
-
- {subjectsQuery.isLoading - ? ( -
- -
- ) - : subjects.length === 0 - ? ( -
- {t('access.members.empty')} -
- ) - : subjects.map((subject) => { - const isSelected = selectedKeys.has(subjectKey(subject)) - const isGroup = subject.subjectType === 'group' - return ( - - ) - })} -
-
-
- )} -
- ) -} - -type EnvironmentPermissionRowProps = { - appId: string - environment: ConsoleEnvironmentSummary - summaryPolicy?: EffectivePolicySummary - onSetPolicy: ( - appId: string, - environmentId: string, - channel: string, - enabled: boolean, - accessMode: string, - subjects: AccessSubject[], - expectedVersion: number, - ) => Promise -} - -const EnvironmentPermissionRow: FC = ({ - 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 ( -
-
- - {environmentName(environment)} - - -
- {permissionKind === 'specific' && ( -
-
- - {subjects.length === 0 && ( - - {t('access.members.emptySelection')} - - )} -
- {subjects.length > 0 && ( -
- {subjects.map(subject => ( - handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))} - /> - ))} -
- )} -
- )} -
- ) -} - -type EndpointRowProps = { - envName: string - label: string - value: string - openLabel?: string -} - -const EndpointRow: FC = ({ envName, label, value, openLabel }) => ( -
- - {envName} - - - {openLabel && ( - - - {openLabel} - - )} -
-) - -type ApiKeyGenerateMenuProps = { - environments: ConsoleEnvironmentSummary[] - onGenerate: (environmentId: string) => void -} - -const ApiKeyGenerateMenu: FC = ({ environments, onGenerate }) => { - const { t } = useTranslation('deployments') - const [open, setOpen] = useState(false) - const selectableEnvironments = environments.filter(env => env.id) - const disabled = selectableEnvironments.length === 0 - - return ( - - - - {t('access.api.newKey')} - - - {open && !disabled && ( - - {selectableEnvironments.map(env => ( - { - setOpen(false) - onGenerate(env.id!) - }} - > - - {t('access.api.newKeyForEnv', { env: environmentName(env) })} - - - ))} - - )} - - ) -} - -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 = ({ 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 = ({ instanceId: appId }) => { return (
-
- {deployedEnvs.length === 0 - ? ( -
- {t('access.runAccess.noEnvs')} -
- ) - : ( -
- {deployedEnvs.map((env) => { - const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy - return ( - - ) - })} -
- )} -
- -
toggleAccessChannel(appId, 'webapp', v, webappChannelVersion)} - /> - )} - > - {runEnabled - ? ( -
-
-
-
-
- {t('access.runAccess.webapp')} -
- - {t('access.channels.followPermission')} - -
-
- {t('access.runAccess.webappDesc')} -
- {webappRows.length > 0 - ? ( -
- {webappRows.map((row) => { - const endpointUrl = webappUrl(row.url) - - return ( - - ) - })} -
- ) - : ( -
- {t('access.runAccess.webappEmpty')} -
- )} -
-
-
-
- {t('access.cli.title')} -
- - {t('access.channels.followPermission')} - -
-
- {t('access.cli.description')} -
- {cliDomain - ? ( - - ) - : ( -
- {t('access.cli.empty')} -
- )} -
-
-
- ) - : ( -
- {t('access.channels.disabled')} -
- )} -
- -
toggleAccessChannel(appId, 'api', v, 0)} - /> - )} - > - {apiEnabled - ? ( -
-
-
- - {t('access.api.backendTitle')} - - - {t('access.api.keyList')} - -
- -
- {visibleCreatedApiToken && ( -
-
-
- - {t('access.api.newTokenTitle')} - - - {t('access.api.newTokenDescription')} - -
- -
- -
- )} - {apiKeys.length === 0 - ? ( -
- {deployedEnvs.length === 0 - ? t('access.api.empty') - : t('access.api.noKeys')} -
- ) - : ( -
- {apiKeys.map((apiKey) => { - if (!apiKey.id || !apiKey.environmentId) - return null - return ( - handleRevokeApiKey(apiKey.environmentId!, apiKey.id!)} - /> - ) - })} -
- )} -
- ) - : ( -
- {t('access.api.disabled')} -
- )} -
+ + toggleAccessChannel(appId, 'webapp', enabled, webappChannelVersion)} + /> + toggleAccessChannel(appId, 'api', enabled, 0)} + onGenerate={handleGenerateApiKey} + onRevoke={handleRevokeApiKey} + onClearCreatedToken={clearCreatedApiToken} + />
) } diff --git a/web/features/deployments/detail/access-tab/api-keys.tsx b/web/features/deployments/detail/access-tab/api-keys.tsx new file mode 100644 index 0000000000..0e98074dbc --- /dev/null +++ b/web/features/deployments/detail/access-tab/api-keys.tsx @@ -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 = ({ 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 ( +
+
+ {apiKey.name || apiKey.id} + + {t('access.api.envPrefix', { env: apiKey.environmentName || apiKey.environmentId })} + +
+
+
+ {displayValue} +
+ + +
+
+ ) +} + +type ApiKeyGenerateMenuProps = { + environments: ConsoleEnvironmentSummary[] + onGenerate: (environmentId: string) => void +} + +export const ApiKeyGenerateMenu: FC = ({ environments, onGenerate }) => { + const { t } = useTranslation('deployments') + const [open, setOpen] = useState(false) + const selectableEnvironments = environments.filter(env => env.id) + const disabled = selectableEnvironments.length === 0 + + return ( + + + + {t('access.api.newKey')} + + + {open && !disabled && ( + + {selectableEnvironments.map(env => ( + { + setOpen(false) + onGenerate(env.id!) + }} + > + + {t('access.api.newKeyForEnv', { env: environmentName(env) })} + + + ))} + + )} + + ) +} diff --git a/web/features/deployments/detail/access-tab/channels-section.tsx b/web/features/deployments/detail/access-tab/channels-section.tsx new file mode 100644 index 0000000000..f6f482d36d --- /dev/null +++ b/web/features/deployments/detail/access-tab/channels-section.tsx @@ -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 = ({ + runEnabled, + webappRows, + cliDomain, + cliDocsUrl, + onToggle, +}) => { + const { t } = useTranslation('deployments') + + return ( +
+ )} + > + {runEnabled + ? ( +
+
+
+
+
+ {t('access.runAccess.webapp')} +
+ + {t('access.channels.followPermission')} + +
+
+ {t('access.runAccess.webappDesc')} +
+ {webappRows.length > 0 + ? ( +
+ {webappRows.map((row) => { + const endpointUrl = webappUrl(row.url) + + return ( + + ) + })} +
+ ) + : ( +
+ {t('access.runAccess.webappEmpty')} +
+ )} +
+
+
+
+ {t('access.cli.title')} +
+ + {t('access.channels.followPermission')} + +
+
+ {t('access.cli.description')} +
+ {cliDomain + ? ( + + ) + : ( +
+ {t('access.cli.empty')} +
+ )} +
+
+
+ ) + : ( +
+ {t('access.channels.disabled')} +
+ )} +
+ ) +} diff --git a/web/features/deployments/detail/access-tab/common.tsx b/web/features/deployments/detail/access-tab/common.tsx new file mode 100644 index 0000000000..ace17dc9d2 --- /dev/null +++ b/web/features/deployments/detail/access-tab/common.tsx @@ -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 = ({ title, description, action, children }) => ( +
+
+
+
{title}
+ {description && ( +

{description}

+ )} +
+ {action} +
+ {children} +
+) + +type CopyPillProps = { + label: string + value: string + prefix?: ReactNode + className?: string +} + +export const CopyPill: FC = ({ 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 ( +
+
+ {label} +
+ {prefix} +
+ {value} +
+
+ +
+ ) +} + +type EndpointRowProps = { + envName: string + label: string + value: string + openLabel?: string +} + +export const EndpointRow: FC = ({ envName, label, value, openLabel }) => ( +
+ + {envName} + + + {openLabel && ( + + + {openLabel} + + )} +
+) diff --git a/web/features/deployments/detail/access-tab/developer-api-section.tsx b/web/features/deployments/detail/access-tab/developer-api-section.tsx new file mode 100644 index 0000000000..d707d072b4 --- /dev/null +++ b/web/features/deployments/detail/access-tab/developer-api-section.tsx @@ -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 = ({ + apiEnabled, + environments, + apiKeys, + createdToken, + onToggle, + onGenerate, + onRevoke, + onClearCreatedToken, +}) => { + const { t } = useTranslation('deployments') + + return ( +
+ )} + > + {apiEnabled + ? ( +
+
+
+ + {t('access.api.backendTitle')} + + + {t('access.api.keyList')} + +
+ +
+ {createdToken && ( +
+
+
+ + {t('access.api.newTokenTitle')} + + + {t('access.api.newTokenDescription')} + +
+ +
+ +
+ )} + {apiKeys.length === 0 + ? ( +
+ {environments.length === 0 + ? t('access.api.empty') + : t('access.api.noKeys')} +
+ ) + : ( +
+ {apiKeys.map((apiKey) => { + if (!apiKey.id || !apiKey.environmentId) + return null + return ( + onRevoke(apiKey.environmentId!, apiKey.id!)} + /> + ) + })} +
+ )} +
+ ) + : ( +
+ {t('access.api.disabled')} +
+ )} +
+ ) +} diff --git a/web/features/deployments/detail/access-tab/permissions-section.tsx b/web/features/deployments/detail/access-tab/permissions-section.tsx new file mode 100644 index 0000000000..8bf722a9c0 --- /dev/null +++ b/web/features/deployments/detail/access-tab/permissions-section.tsx @@ -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 +} + +export const AccessPermissionsSection: FC = ({ + appId, + environments, + policies, + onSetPolicy, +}) => { + const { t } = useTranslation('deployments') + + return ( +
+ {environments.length === 0 + ? ( +
+ {t('access.runAccess.noEnvs')} +
+ ) + : ( +
+ {environments.map((env) => { + const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/web/features/deployments/detail/access-tab/permissions.tsx b/web/features/deployments/detail/access-tab/permissions.tsx new file mode 100644 index 0000000000..2fb062aef0 --- /dev/null +++ b/web/features/deployments/detail/access-tab/permissions.tsx @@ -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 = { + 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 = ({ value, disabled, onChange }) => { + const { t } = useTranslation('deployments') + const icon = permissionIcon[value] + const label = t(`access.permission.${value}`) + + return ( + + + + {label} + + + + {permissionOrder.map((kind) => { + const itemIcon = permissionIcon[kind] + const isSelected = kind === value + return ( + onChange(kind)} + className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2" + > + +
+
+ + {t(`access.permission.${kind}`)} + +
+ + {t(`access.permission.${kind}Desc`)} + +
+ {isSelected && ( + + )} +
+ ) + })} +
+
+ ) +} + +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) { + 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 = ({ subject, disabled, onRemove }) => { + const { t } = useTranslation('deployments') + const isGroup = subject.subjectType === 'group' + + return ( +
+ + {subject.name || subject.id} + {isGroup && subject.memberCount != null && ( + {subject.memberCount} + )} + +
+ ) +} + +type SubjectPickerProps = { + appId: string + disabled?: boolean + selectedSubjects: SelectableAccessSubject[] + onChange: (subjects: SelectableAccessSubject[]) => void +} + +const SubjectPicker: FC = ({ + 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 ( + + + + {t('access.members.pickPlaceholder')} + + )} + /> + {open && ( + +
+
+
+ + 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" + /> +
+
+
+ {subjectsQuery.isLoading + ? ( +
+ +
+ ) + : subjects.length === 0 + ? ( +
+ {t('access.members.empty')} +
+ ) + : subjects.map((subject) => { + const isSelected = selectedKeys.has(subjectKey(subject)) + const isGroup = subject.subjectType === 'group' + return ( + + ) + })} +
+
+
+ )} +
+ ) +} + +type EnvironmentPermissionRowProps = { + appId: string + environment: ConsoleEnvironmentSummary + summaryPolicy?: EffectivePolicySummary + onSetPolicy: ( + appId: string, + environmentId: string, + channel: string, + enabled: boolean, + accessMode: string, + subjects: AccessSubject[], + expectedVersion: number, + ) => Promise +} + +export const EnvironmentPermissionRow: FC = ({ + 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 ( +
+
+ + {environmentName(environment)} + + +
+ {permissionKind === 'specific' && ( +
+
+ + {subjects.length === 0 && ( + + {t('access.members.emptySelection')} + + )} +
+ {subjects.length > 0 && ( +
+ {subjects.map(subject => ( + handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))} + /> + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/web/features/deployments/detail/access-tab/url.ts b/web/features/deployments/detail/access-tab/url.ts new file mode 100644 index 0000000000..fe70304308 --- /dev/null +++ b/web/features/deployments/detail/access-tab/url.ts @@ -0,0 +1,10 @@ +export function getUrlOrigin(url?: string) { + if (!url) + return undefined + try { + return new URL(url).origin + } + catch { + return url + } +} diff --git a/web/features/deployments/detail/deploy-tab.tsx b/web/features/deployments/detail/deploy-tab.tsx index aca4487c16..600c91c096 100644 --- a/web/features/deployments/detail/deploy-tab.tsx +++ b/web/features/deployments/detail/deploy-tab.tsx @@ -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 = ({ title, children }) => ( -
-
{title}
-
{children}
-
-) - -type InfoRowProps = { - label: string - value: React.ReactNode - mono?: boolean - suffix?: string -} - -const InfoRow: FC = ({ label, value, mono, suffix }) => ( -
- {label} - - {value} - {suffix && {suffix}} - -
-) - -type DeploymentPanelProps = { - row: EnvironmentDeploymentRow -} - -const DeploymentPanel: FC = ({ 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 ( -
-
- - {environmentName(env)} - {' · '} - {releaseLabel(observed || pending)} - - - -
-
- - - - - - - - - - - - {pending && ( - - )} - {row.instance?.lastError?.releaseId && ( - - )} - - - - - - - - {credentials.length > 0 && ( - - {credentials.map(c => ( - - ))} - - )} - - {envVars.length > 0 && ( - - {envVars.map(v => ( - - ))} - - )} -
- - {row.instance?.lastError?.message && ( -
- {row.instance.lastError.message} -
- )} -
- ) -} - -type DeploymentStatusSummaryProps = { - row: EnvironmentDeploymentRow -} - -const DeploymentStatusSummary: FC = ({ row }) => { - const { t } = useTranslation('deployments') - const status = deploymentStatus(row) - - if (status === 'deploying') { - return ( - - - {t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease(row) || activeRelease(row)) })} - - ) - } - - if (status === 'deploy_failed') { - return ( - - - {t('deployTab.status.runningWithFailed')} - - ) - } - - return ( - - - {t('status.ready')} - - ) -} - type DeployTabProps = { instanceId: string } diff --git a/web/features/deployments/detail/deploy-tab/deployment-panel.tsx b/web/features/deployments/detail/deploy-tab/deployment-panel.tsx new file mode 100644 index 0000000000..4891f63a5e --- /dev/null +++ b/web/features/deployments/detail/deploy-tab/deployment-panel.tsx @@ -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 = ({ title, children }) => ( +
+
{title}
+
{children}
+
+) + +type InfoRowProps = { + label: string + value: ReactNode + mono?: boolean + suffix?: string +} + +const InfoRow: FC = ({ label, value, mono, suffix }) => ( +
+ {label} + + {value} + {suffix && {suffix}} + +
+) + +type DeploymentPanelProps = { + row: EnvironmentDeploymentRow +} + +export const DeploymentPanel: FC = ({ 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 ( +
+
+ + {environmentName(env)} + {' · '} + {releaseLabel(observed || pending)} + + + +
+
+ + + + + + + + + + + + {pending && ( + + )} + {row.instance?.lastError?.releaseId && ( + + )} + + + + + + + + {credentials.length > 0 && ( + + {credentials.map(c => ( + + ))} + + )} + + {envVars.length > 0 && ( + + {envVars.map(v => ( + + ))} + + )} +
+ + {row.instance?.lastError?.message && ( +
+ {row.instance.lastError.message} +
+ )} +
+ ) +} diff --git a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx b/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx new file mode 100644 index 0000000000..645bc1ab61 --- /dev/null +++ b/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx @@ -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 = ({ row }) => { + const { t } = useTranslation('deployments') + const status = deploymentStatus(row) + + if (status === 'deploying') { + return ( + + + {t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease(row) || activeRelease(row)) })} + + ) + } + + if (status === 'deploy_failed') { + return ( + + + {t('deployTab.status.runningWithFailed')} + + ) + } + + return ( + + + {t('status.ready')} + + ) +} diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx new file mode 100644 index 0000000000..0f6da929df --- /dev/null +++ b/web/features/deployments/detail/deployment-sidebar.tsx @@ -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> & { + title?: string + titleId?: string +} + +const OverviewIcon = ({ className }: TailwindNavIconProps) => +const OverviewSelectedIcon = ({ className }: TailwindNavIconProps) => +const DeployIcon = ({ className }: TailwindNavIconProps) => +const DeploySelectedIcon = ({ className }: TailwindNavIconProps) => +const VersionsIcon = ({ className }: TailwindNavIconProps) => +const VersionsSelectedIcon = ({ className }: TailwindNavIconProps) => +const AccessIcon = ({ className }: TailwindNavIconProps) => +const AccessSelectedIcon = ({ className }: TailwindNavIconProps) => +const SettingsIcon = ({ className }: TailwindNavIconProps) => +const SettingsSelectedIcon = ({ className }: TailwindNavIconProps) => + +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 = ({ + instanceId, + instanceName, + instanceDescription, + appModeLabel, + app, +}) => { + const { t } = useTranslation('deployments') + const sidebarRef = useRef(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 ( + + ) +} diff --git a/web/features/deployments/detail/index.tsx b/web/features/deployments/detail/index.tsx index 26b3c28573..370b0b35af 100644 --- a/web/features/deployments/detail/index.tsx +++ b/web/features/deployments/detail/index.tsx @@ -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> & { - title?: string - titleId?: string -} - -const OverviewIcon = ({ className }: TailwindNavIconProps) => -const OverviewSelectedIcon = ({ className }: TailwindNavIconProps) => -const DeployIcon = ({ className }: TailwindNavIconProps) => -const DeploySelectedIcon = ({ className }: TailwindNavIconProps) => -const VersionsIcon = ({ className }: TailwindNavIconProps) => -const VersionsSelectedIcon = ({ className }: TailwindNavIconProps) => -const AccessIcon = ({ className }: TailwindNavIconProps) => -const AccessSelectedIcon = ({ className }: TailwindNavIconProps) => -const SettingsIcon = ({ className }: TailwindNavIconProps) => -const SettingsSelectedIcon = ({ className }: TailwindNavIconProps) => - -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 = ({ - instanceId, - instanceName, - instanceDescription, - appModeLabel, - app, -}) => { - const { t } = useTranslation('deployments') - const sidebarRef = useRef(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 ( - - ) -} - const InstanceDetail: FC = ({ instanceId, children }) => { const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation() diff --git a/web/features/deployments/detail/versions-tab.tsx b/web/features/deployments/detail/versions-tab.tsx index ff331355cd..1fa63edc9a 100644 --- a/web/features/deployments/detail/versions-tab.tsx +++ b/web/features/deployments/detail/versions-tab.tsx @@ -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 = { - 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 = ({ 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 ( - - - {t('versions.deploy')} - - - {open && ( - - {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 ( - { - setOpen(false) - if (disabled) - return - if (row) { - openRollbackModal({ - appId, - environmentId: envId, - deploymentId: deploymentId(row), - targetReleaseId: releaseId, - }) - return - } - openDeployDrawer({ appId, environmentId: envId, releaseId }) - }} - > - - {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) })} - - - ) - })} - - )} - - ) -} - -const DeployedToBadge: FC<{ item: ReleaseDeployment }> = ({ item }) => { - const { t } = useTranslation('deployments') - const statusLabel = t(`versions.deployedStatus.${item.state}`) - - return ( - - - {item.state === 'deploying' - ? - : item.state === 'failed' - ? - : } - {item.environmentName} - - )} - /> - - {statusLabel} - {' · '} - {item.environmentName} - - - ) -} - type VersionsTabProps = { instanceId: string } @@ -190,45 +33,6 @@ const VersionsTab: FC = ({ 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 (
@@ -265,7 +69,7 @@ const VersionsTab: FC = ({ instanceId: appId }) => { {releaseRows.map((row) => { const release = row.release! - const releaseDeployments = getReleaseDeployments(row) + const releaseDeployments = getReleaseDeployments(row, deploymentRows) return (
diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx new file mode 100644 index 0000000000..c809a9a8bb --- /dev/null +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -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 = ({ 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 ( + + + {t('versions.deploy')} + + + {open && ( + + {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 ( + { + setOpen(false) + if (disabled) + return + if (row) { + openRollbackModal({ + appId, + environmentId: envId, + deploymentId: deploymentId(row), + targetReleaseId: releaseId, + }) + return + } + openDeployDrawer({ appId, environmentId: envId, releaseId }) + }} + > + + {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) })} + + + ) + })} + + )} + + ) +} diff --git a/web/features/deployments/detail/versions-tab/deployed-to-badge.tsx b/web/features/deployments/detail/versions-tab/deployed-to-badge.tsx new file mode 100644 index 0000000000..4f7ceda703 --- /dev/null +++ b/web/features/deployments/detail/versions-tab/deployed-to-badge.tsx @@ -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 = { + 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 = ({ item }) => { + const { t } = useTranslation('deployments') + const statusLabel = t(`versions.deployedStatus.${item.state}`) + + return ( + + + {item.state === 'deploying' + ? + : item.state === 'failed' + ? + : } + {item.environmentName} + + )} + /> + + {statusLabel} + {' · '} + {item.environmentName} + + + ) +} diff --git a/web/features/deployments/detail/versions-tab/release-deployments.ts b/web/features/deployments/detail/versions-tab/release-deployments.ts new file mode 100644 index 0000000000..2184b82131 --- /dev/null +++ b/web/features/deployments/detail/versions-tab/release-deployments.ts @@ -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]) +} diff --git a/web/features/deployments/hooks/use-deployment-data.ts b/web/features/deployments/hooks/use-deployment-data.ts index a7e25b053b..ec0bf4487b 100644 --- a/web/features/deployments/hooks/use-deployment-data.ts +++ b/web/features/deployments/hooks/use-deployment-data.ts @@ -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 = { diff --git a/web/features/deployments/list/environment-filter.tsx b/web/features/deployments/list/environment-filter.tsx new file mode 100644 index 0000000000..f719011278 --- /dev/null +++ b/web/features/deployments/list/environment-filter.tsx @@ -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 = ({ value, options, onChange }) => { + const [open, setOpen] = useState(false) + const selectedOption = options.find(option => option.value === value) ?? options[0] + + return ( + + +
+ {selectedOption?.icon} +
+
+ {selectedOption?.text} +
+
+ +
+
+ {open && ( + +
+ {options.map(option => ( + { + 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', + )} + > + {option.icon} + {option.text} + {option.value === value && ( + + )} + + ))} +
+
+ )} +
+ ) +} diff --git a/web/features/deployments/list/index.tsx b/web/features/deployments/list/index.tsx index 3b70978c34..6479bdb3e2 100644 --- a/web/features/deployments/list/index.tsx +++ b/web/features/deployments/list/index.tsx @@ -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 = ({ icon, label, disabled, onClick }) => { - const { t } = useTranslation('deployments') - - return ( - - ) -} - -const NewInstanceCard: FC = ({ onOpen }) => { - const { t } = useTranslation('deployments') - return ( -
-
-
- {t('newInstance.title')} -
- - - -
-
- ) -} - -type InstanceCardProps = { - app: AppInfo - appData?: DeploymentAppData - summary?: AppDeploymentSummary -} - -const InstanceCard: FC = ({ 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, 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) => { - 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 - ? ( -
-
{t('overview.deploymentStatus')}
- {deployments.map((deployment) => { - const status = deploymentStatus(deployment) - return ( -
- - {environmentName(deployment.environment)} - - - {statusLabel(status)} - {' · '} - {releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)} - -
- ) - })} -
- ) - : ( -
-
{t('overview.deploymentStatus')}
- {statusSummaryTooltip.map(item => ( -
- {statusSummaryLabel(item.status)} - {item.count} -
- ))} -
- ) - - 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 ( -
{ - 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" - > -
-
- - -
-
-
-
{app.name}
-
-
- {appModeLabel} -
-
-
-
- - - - - {primaryText} - - {secondaryParts.length > 0 && ( - - {secondaryParts.join(' · ')} - - )} -
- )} - /> - {statusTooltip} - -
- - - {t('card.fromApp', { name: app.name })} - -
-
-
-
- - - {lastDeployedAt - ? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) }) - : t('card.neverDeployed')} - -
-
- - { - e.stopPropagation() - e.preventDefault() - }} - > - - - {menuOpen && ( - - handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))} - > - {t('card.menu.deploy')} - - handleMenuAction(e, navigateToDetail)} - > - {t('card.menu.viewDetail')} - - - { - e.stopPropagation() - e.preventDefault() - }} - > - {t('card.menu.delete')} - - - )} - -
-
-
- ) -} - -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 = ({ value, options, onChange }) => { - const [open, setOpen] = useState(false) - const selectedOption = options.find(option => option.value === value) ?? options[0] - - return ( - - -
- {selectedOption?.icon} -
-
- {selectedOption?.text} -
-
- -
-
- {open && ( - -
- {options.map(option => ( - { - 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', - )} - > - {option.icon} - {option.text} - {option.value === value && ( - - )} - - ))} -
-
- )} -
- ) -} +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 = () => {
- {visibleInstances.map((app) => { - return ( - - ) - })} + {visibleInstances.map(app => ( + + ))}
diff --git a/web/features/deployments/list/instance-card.tsx b/web/features/deployments/list/instance-card.tsx new file mode 100644 index 0000000000..4dd1df7ff3 --- /dev/null +++ b/web/features/deployments/list/instance-card.tsx @@ -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 = ({ 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, 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) => { + 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 + ? ( +
+
{t('overview.deploymentStatus')}
+ {deployments.map((deployment) => { + const status = deploymentStatus(deployment) + return ( +
+ + {environmentName(deployment.environment)} + + + {statusLabel(status)} + {' · '} + {releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)} + +
+ ) + })} +
+ ) + : ( +
+
{t('overview.deploymentStatus')}
+ {statusSummaryTooltip.map(item => ( +
+ {statusSummaryLabel(item.status)} + {item.count} +
+ ))} +
+ ) + + 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 ( +
{ + 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" + > +
+
+ + +
+
+
+
{app.name}
+
+
+ {appModeLabel} +
+
+
+
+ + + + + {primaryText} + + {secondaryParts.length > 0 && ( + + {secondaryParts.join(' · ')} + + )} +
+ )} + /> + {statusTooltip} + +
+ + + {t('card.fromApp', { name: app.name })} + +
+
+
+
+ + + {lastDeployedAt + ? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) }) + : t('card.neverDeployed')} + +
+
+ + { + e.stopPropagation() + e.preventDefault() + }} + > + + + {menuOpen && ( + + handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))} + > + {t('card.menu.deploy')} + + handleMenuAction(e, navigateToDetail)} + > + {t('card.menu.viewDetail')} + + + { + e.stopPropagation() + e.preventDefault() + }} + > + {t('card.menu.delete')} + + + )} + +
+
+
+ ) +} diff --git a/web/features/deployments/list/new-instance-card.tsx b/web/features/deployments/list/new-instance-card.tsx new file mode 100644 index 0000000000..7b6c648cd0 --- /dev/null +++ b/web/features/deployments/list/new-instance-card.tsx @@ -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 = ({ icon, label, disabled, onClick }) => { + const { t } = useTranslation('deployments') + + return ( + + ) +} + +export const NewInstanceCard: FC = ({ onOpen }) => { + const { t } = useTranslation('deployments') + return ( +
+
+
+ {t('newInstance.title')} +
+ + + +
+
+ ) +} diff --git a/web/features/deployments/store.ts b/web/features/deployments/store.ts index 69414d3b4f..c23333d369 100644 --- a/web/features/deployments/store.ts +++ b/web/features/deployments/store.ts @@ -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