diff --git a/web/app/components/deployments/instance-detail/access-tab.tsx b/web/app/components/deployments/instance-detail/access-tab.tsx index fa154569b1..4c3727ff73 100644 --- a/web/app/components/deployments/instance-detail/access-tab.tsx +++ b/web/app/components/deployments/instance-detail/access-tab.tsx @@ -1,7 +1,15 @@ 'use client' import type { FC, ReactNode } from 'react' import type { AccessPermissionKind } from '../types' -import type { ConsoleEnvironmentSummary, DeveloperAPIKeySummary } from '@/contract/console/deployments' +import type { + AccessPolicyDetail, + AccessSubject, + AccessSubjectDisplay, + APIToken, + ConsoleEnvironmentSummary, + DeveloperAPIKeySummary, + EffectivePolicySummary, +} from '@/contract/console/deployments' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -9,10 +17,14 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' +import { skipToken, useQueries, useQuery } from '@tanstack/react-query' +import { useDebounce } from 'ahooks' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' import { accessModeToPermissionKey, deployedRows, @@ -213,6 +225,357 @@ const PermissionPicker: FC = ({ value, disabled, onChange ) } +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 @@ -338,9 +701,49 @@ const AccessTab: FC = ({ instanceId: appId }) => { ]), [accessConfig?.webapp?.rows, deploymentRows, policies], ) - const webappRows = accessConfig?.webapp?.rows?.filter(row => row.url) ?? [] - const apiKeys = accessConfig?.developerApi?.apiKeys ?? [] const apiEnabled = accessConfig?.developerApi?.enabled ?? false + const apiTokenEnvironments = useMemo( + () => deployedEnvs.filter((env): env is ConsoleEnvironmentSummary & { id: string } => Boolean(env.id)), + [deployedEnvs], + ) + const apiTokenQueries = useQueries({ + queries: apiTokenEnvironments.map(env => consoleQuery.deployments.environmentAPITokens.queryOptions({ + input: { + params: { + appId, + environmentId: env.id, + }, + }, + enabled: apiEnabled, + staleTime: 30 * 1000, + })), + }) + const apiTokenRows = apiTokenQueries.flatMap((query, index): DeveloperAPIKeySummary[] => { + const env = apiTokenEnvironments[index] + return query.data?.data?.map((token: APIToken) => ({ + ...token, + environmentName: token.environmentId ? environmentName(env) : undefined, + })) ?? [] + }) + const apiKeys = apiTokenQueries.some(query => query.isSuccess) + ? apiTokenRows + : accessConfig?.developerApi?.apiKeys ?? [] + const refetchApiTokens = async () => { + await Promise.all(apiTokenQueries.map(query => query.refetch())) + } + const handleGenerateApiKey = (environmentId: string) => { + void (async () => { + await generateApiKey(appId, environmentId) + await refetchApiTokens() + })() + } + const handleRevokeApiKey = (environmentId: string, apiKeyId: string) => { + void (async () => { + await revokeApiKey(appId, environmentId, apiKeyId) + await refetchApiTokens() + })() + } + const webappRows = accessConfig?.webapp?.rows?.filter(row => row.url) ?? [] const runEnabled = accessConfig?.webapp?.enabled ?? false const visibleCreatedApiToken = createdApiToken?.appId === appId ? createdApiToken : undefined const webappChannelVersion = policies.find(policy => policy.effectivePolicy?.channel === 'webapp')?.effectivePolicy?.version ?? 0 @@ -363,34 +766,14 @@ const AccessTab: FC = ({ instanceId: appId }) => {
{deployedEnvs.map((env) => { const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy - const kind = accessModeToPermissionKey(policy?.accessMode) return ( -
-
- - {environmentName(env)} - - setEnvironmentAccessPolicy( - appId, - env.id!, - policy?.channel ?? 'webapp', - true, - permissionKeyToAccessMode(next), - policy?.version ?? 0, - )} - /> -
- {kind === 'specific' && ( -
- {t('access.permission.specificUnavailable')} -
- )} -
+ appId={appId} + environment={env} + summaryPolicy={policy} + onSetPolicy={setEnvironmentAccessPolicy} + /> ) })}
@@ -527,7 +910,7 @@ const AccessTab: FC = ({ instanceId: appId }) => { generateApiKey(appId, environmentId)} + onGenerate={handleGenerateApiKey} /> {visibleCreatedApiToken && ( @@ -573,7 +956,7 @@ const AccessTab: FC = ({ instanceId: appId }) => { revokeApiKey(appId, apiKey.environmentId!, apiKey.id!)} + onRevoke={() => handleRevokeApiKey(apiKey.environmentId!, apiKey.id!)} /> ) })} diff --git a/web/app/components/deployments/instance-detail/index.tsx b/web/app/components/deployments/instance-detail/index.tsx index 159d0bfe12..304fa02fdc 100644 --- a/web/app/components/deployments/instance-detail/index.tsx +++ b/web/app/components/deployments/instance-detail/index.tsx @@ -23,6 +23,7 @@ import { deployedRows, deploymentStatus } from '../api-utils' import DeployDrawer from '../deploy-drawer' import RollbackModal from '../rollback-modal' import { useDeploymentsStore } from '../store' +import { useDeploymentData } from '../use-deployment-data' import { useSourceApps } from '../use-source-apps' import { isInstanceDetailTabKey } from './tabs' @@ -229,6 +230,8 @@ const InstanceDetail: FC = ({ instanceId, children }) => { () => sourceApps.find(item => item.id === instanceId) ?? appMap.get(instanceId), [sourceApps, instanceId, appMap], ) + const detailApps = useMemo(() => app ? [app] : [], [app]) + useDeploymentData(detailApps, { enabled: detailApps.length > 0 }) const appDeployments = useMemo( () => deployedRows(appData[instanceId]?.environmentDeployments.environmentDeployments), [appData, instanceId], diff --git a/web/app/components/deployments/instance-detail/overview-tab.tsx b/web/app/components/deployments/instance-detail/overview-tab.tsx index d02108ebb6..b55ed715c4 100644 --- a/web/app/components/deployments/instance-detail/overview-tab.tsx +++ b/web/app/components/deployments/instance-detail/overview-tab.tsx @@ -6,7 +6,7 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels' -import { deployedRows, deploymentStatus, environmentName, formatDate, releaseLabel, webappUrl } from '../api-utils' +import { webappUrl } from '../api-utils' import { StatusBadge } from '../status-badge' import { useDeploymentsStore } from '../store' import { useSourceApps } from '../use-source-apps' @@ -76,6 +76,15 @@ const AccessOverviewRow: FC = ({ label, enabled, hint }) ) } +function overviewDeploymentStatus(status?: string) { + const normalized = status?.toLowerCase() ?? '' + if (normalized.includes('deploying') || normalized.includes('pending')) + return 'deploying' + if (normalized.includes('fail') || normalized.includes('error')) + return 'deploy_failed' + return 'ready' +} + const OverviewTab: FC = ({ instanceId, onSwitchTab }) => { const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation() @@ -83,16 +92,17 @@ const OverviewTab: FC = ({ instanceId, onSwitchTab }) => { const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer) const { appMap } = useSourceApps() const app = appMap.get(instanceId) - + const overview = appData?.overview + const overviewApp = overview?.app const deployments = useMemo( - () => deployedRows(appData?.environmentDeployments.environmentDeployments), - [appData?.environmentDeployments.environmentDeployments], + () => overview?.deployments?.filter(row => row.environmentId && row.status?.toLowerCase() !== 'undeployed') ?? [], + [overview?.deployments], ) if (!app) return null - const appModeLabel = getAppModeLabel(app.mode, tCommon) + const appModeLabel = getAppModeLabel(overviewApp?.mode ?? app.mode, tCommon) const webappRow = appData?.accessConfig.webapp?.rows?.find(row => row.url) const webappAccessUrl = webappUrl(webappRow?.url) const cliUrl = appData?.accessConfig.cli?.url @@ -102,8 +112,8 @@ const OverviewTab: FC = ({ instanceId, onSwitchTab }) => {
- - + +
@@ -131,15 +141,13 @@ const OverviewTab: FC = ({ instanceId, onSwitchTab }) => { : (
{deployments.map((row) => { - const status = deploymentStatus(row) + const status = overviewDeploymentStatus(row.status) return ( -
+
- {environmentName(row.environment)} + {row.environmentName || row.environmentId} - {releaseLabel(row.observedRuntime?.release || row.pendingDeployment?.release)} - {' · '} - {formatDate(row.instance?.lastDeployedAt || row.instance?.lastReadyAt)} + {row.releaseDisplayId || row.releaseId || t('overview.emptyValue')}
@@ -162,18 +170,18 @@ const OverviewTab: FC = ({ instanceId, onSwitchTab }) => {
diff --git a/web/app/components/deployments/store.ts b/web/app/components/deployments/store.ts index 8977eaa8ea..2dbc5bf228 100644 --- a/web/app/components/deployments/store.ts +++ b/web/app/components/deployments/store.ts @@ -1,5 +1,5 @@ import type { AppInfo } from './types' -import type { APIToken, BindingsProto } from '@/contract/console/deployments' +import type { AccessSubject, APIToken, BindingsProto } from '@/contract/console/deployments' import type { DeploymentAppData } from '@/service/deployments' import { create } from 'zustand' import { @@ -99,6 +99,7 @@ type DeploymentsState = { channel: string, enabled: boolean, accessMode: string, + subjects: AccessSubject[], expectedVersion: number, ) => Promise } @@ -223,8 +224,8 @@ export const useDeploymentsStore = create((set, get) => ({ await get().refreshAppData(appId) }, - setEnvironmentAccessPolicy: async (appId, environmentId, channel, enabled, accessMode, expectedVersion) => { - await updateEnvironmentAccessPolicy(appId, environmentId, channel, enabled, accessMode, [], expectedVersion) + setEnvironmentAccessPolicy: async (appId, environmentId, channel, enabled, accessMode, subjects, expectedVersion) => { + await updateEnvironmentAccessPolicy(appId, environmentId, channel, enabled, accessMode, subjects, expectedVersion) await get().refreshAppData(appId) }, })) diff --git a/web/app/components/deployments/use-source-apps.ts b/web/app/components/deployments/use-source-apps.ts index 53e544c202..2174241327 100644 --- a/web/app/components/deployments/use-source-apps.ts +++ b/web/app/components/deployments/use-source-apps.ts @@ -5,7 +5,6 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' import { consoleQuery } from '@/service/client' import { useDeploymentsStore } from './store' -import { useDeploymentData } from './use-deployment-data' const MAX_SOURCE_APPS = 100 @@ -74,16 +73,14 @@ export function useSourceApps(options: UseSourceAppsOptions = {}) { seedInstancesFromApps(apps) }, [apps, enabled, listQuery.isLoading, seedInstancesFromApps]) - const deploymentData = useDeploymentData(apps, { enabled: enabled && apps.length > 0 }) - return { apps, appMap, summaries, environmentOptions, isLoading: listQuery.isLoading, - isFetching: listQuery.isFetching || deploymentData.isFetching, - isError: listQuery.isError || deploymentData.isError, + isFetching: listQuery.isFetching, + isError: listQuery.isError, isEmpty: !listQuery.isLoading && apps.length === 0, } } diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index abeeec3ef6..f013d133c4 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -31,6 +31,7 @@ "access.hide": "Hide", "access.members.clearAll": "Clear all", "access.members.empty": "No matches found.", + "access.members.emptySelection": "Choose at least one group or member to save this permission.", "access.members.groupCount_one": "{{count}} group", "access.members.groupCount_other": "{{count}} groups", "access.members.groups": "Groups", @@ -52,6 +53,7 @@ "access.permission.specific": "Specific members", "access.permission.specificDesc": "Pick groups or individual members", "access.permission.specificUnavailable": "Specific member selection is disabled until real workspace subjects are connected.", + "access.permission.updateFailed": "Failed to update access policy.", "access.permissions.description": "Configure who can access this instance in each deployed environment.", "access.permissions.title": "Access permissions", "access.revoke": "Revoke", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index 2862b487ab..dd8196a650 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -31,6 +31,7 @@ "access.hide": "隐藏", "access.members.clearAll": "全部清除", "access.members.empty": "未找到匹配结果。", + "access.members.emptySelection": "至少选择一个分组或成员后才会保存该权限。", "access.members.groupCount_one": "{{count}} 个分组", "access.members.groupCount_other": "{{count}} 个分组", "access.members.groups": "分组", @@ -52,6 +53,7 @@ "access.permission.specific": "特定成员", "access.permission.specificDesc": "选择指定的分组或单个成员", "access.permission.specificUnavailable": "特定成员暂未启用,需接入真实工作区成员与分组后再开放。", + "access.permission.updateFailed": "更新访问策略失败。", "access.permissions.description": "配置该实例在每个已部署环境中的访问人员。", "access.permissions.title": "访问权限", "access.revoke": "撤销",