This commit is contained in:
Stephen Zhou 2026-04-28 14:50:41 +08:00
parent 111483c73a
commit af6aac3094
No known key found for this signature in database
7 changed files with 452 additions and 56 deletions

View File

@ -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<PermissionPickerProps> = ({ 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<SelectableAccessSubject, 'id' | 'subjectType'>) {
return `${subject.subjectType}:${subject.id}`
}
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
return subjects.map(subject => ({
subjectId: subject.id,
subjectType: subject.subjectType,
}))
}
function selectedSubjectsFromPolicy(policy?: AccessPolicyDetail) {
const selectedOption = policy?.options?.find(option => option.selected)
?? policy?.options?.find(option => option.mode === policy?.accessMode)
return [
...(selectedOption?.groups ?? []),
...(selectedOption?.members ?? []),
].map(normalizeSubject).filter((subject): subject is SelectableAccessSubject => Boolean(subject))
}
type SubjectPillProps = {
subject: SelectableAccessSubject
disabled?: boolean
onRemove: () => void
}
const SubjectPill: FC<SubjectPillProps> = ({ subject, disabled, onRemove }) => {
const { t } = useTranslation('deployments')
const isGroup = subject.subjectType === 'group'
return (
<div className="inline-flex max-w-full items-center gap-1 rounded-full border border-divider-subtle bg-components-badge-white-to-dark px-2 py-1">
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
<span className="truncate system-xs-medium text-text-secondary">{subject.name || subject.id}</span>
{isGroup && subject.memberCount != null && (
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
)}
<button
type="button"
disabled={disabled}
onClick={onRemove}
aria-label={t('operation.remove', { ns: 'common' })}
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-text-quaternary hover:text-text-secondary',
disabled && 'cursor-not-allowed opacity-40',
)}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
</button>
</div>
)
}
type SubjectPickerProps = {
appId: string
disabled?: boolean
selectedSubjects: SelectableAccessSubject[]
onChange: (subjects: SelectableAccessSubject[]) => void
}
const SubjectPicker: FC<SubjectPickerProps> = ({
appId,
disabled,
selectedSubjects,
onChange,
}) => {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
const selectedKeys = useMemo(
() => new Set(selectedSubjects.map(subjectKey)),
[selectedSubjects],
)
const subjectsQuery = useQuery(consoleQuery.deployments.searchAccessSubjects.queryOptions({
input: open
? {
params: { appId },
query: {
keyword: debouncedKeyword.trim() || undefined,
subjectTypes: ['account', 'group'],
},
}
: skipToken,
staleTime: 30 * 1000,
}))
const subjects = useMemo(
() => subjectsQuery.data?.data
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? [],
[subjectsQuery.data?.data],
)
const toggleSubject = (subject: SelectableAccessSubject) => {
const key = subjectKey(subject)
if (selectedKeys.has(key)) {
if (selectedSubjects.length <= 1)
return
onChange(selectedSubjects.filter(item => subjectKey(item) !== key))
return
}
onChange([...selectedSubjects, subject])
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
disabled={disabled}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span className="i-ri-add-line h-3.5 w-3.5" />
{t('access.members.pickPlaceholder')}
</button>
)}
/>
{open && (
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[360px] p-0">
<div className="flex max-h-[420px] flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="border-b border-divider-subtle p-2">
<div className="flex h-8 items-center gap-2 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
<span className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder={t('access.members.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent system-sm-regular text-text-primary outline-none placeholder:text-text-quaternary"
/>
</div>
</div>
<div className="min-h-10 overflow-y-auto p-1">
{subjectsQuery.isLoading
? (
<div className="flex h-16 items-center justify-center">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
</div>
)
: subjects.length === 0
? (
<div className="px-3 py-5 text-center system-xs-regular text-text-tertiary">
{t('access.members.empty')}
</div>
)
: subjects.map((subject) => {
const isSelected = selectedKeys.has(subjectKey(subject))
const isGroup = subject.subjectType === 'group'
return (
<button
key={subjectKey(subject)}
type="button"
onClick={() => toggleSubject(subject)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left hover:bg-state-base-hover"
>
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'h-4 w-4 shrink-0 text-text-tertiary')} />
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
{subject.name || subject.id}
</span>
{isGroup && subject.memberCount != null && (
<span className="system-xs-regular text-text-tertiary">
{t('access.members.memberCount', { count: subject.memberCount })}
</span>
)}
{isSelected && (
<span className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</button>
)
})}
</div>
</div>
</PopoverContent>
)}
</Popover>
)
}
type EnvironmentPermissionRowProps = {
appId: string
environment: ConsoleEnvironmentSummary
summaryPolicy?: EffectivePolicySummary
onSetPolicy: (
appId: string,
environmentId: string,
channel: string,
enabled: boolean,
accessMode: string,
subjects: AccessSubject[],
expectedVersion: number,
) => Promise<void>
}
const EnvironmentPermissionRow: FC<EnvironmentPermissionRowProps> = ({
appId,
environment,
summaryPolicy,
onSetPolicy,
}) => {
const { t } = useTranslation('deployments')
const environmentId = environment.id
const channel = summaryPolicy?.channel ?? 'webapp'
const policyQuery = useQuery(consoleQuery.deployments.environmentAccessPolicy.queryOptions({
input: environmentId
? {
params: {
appId,
environmentId,
channel,
},
}
: skipToken,
staleTime: 30 * 1000,
}))
const detailPolicy = policyQuery.data?.policy
const policyKind = accessModeToPermissionKey(detailPolicy?.accessMode ?? summaryPolicy?.accessMode)
const policyFingerprint = [
detailPolicy?.id ?? 'new',
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
detailPolicy?.accessMode ?? summaryPolicy?.accessMode ?? '',
].join(':')
const policySelectedSubjects = useMemo(
() => policyKind === 'specific' ? selectedSubjectsFromPolicy(detailPolicy) : [],
[detailPolicy, policyKind],
)
const [draft, setDraft] = useState<{
fingerprint?: string
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const [isSaving, setIsSaving] = useState(false)
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
const persistPolicy = async (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
if (!environmentId)
return
if (nextKind === 'specific' && nextSubjects.length === 0)
return
setIsSaving(true)
try {
await onSetPolicy(
appId,
environmentId,
detailPolicy?.channel ?? channel,
detailPolicy?.enabled ?? summaryPolicy?.enabled ?? true,
permissionKeyToAccessMode(nextKind),
nextKind === 'specific' ? policySubjects(nextSubjects) : [],
detailPolicy?.version ?? summaryPolicy?.version ?? 0,
)
await policyQuery.refetch()
setDraft({})
}
catch {
toast.error(t('access.permission.updateFailed'))
}
finally {
setIsSaving(false)
}
}
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
setDraft({
fingerprint: policyFingerprint,
kind: nextKind,
subjects: nextKind === 'specific' ? subjects : [],
})
if (nextKind === 'specific') {
void persistPolicy(nextKind, subjects)
return
}
void persistPolicy(nextKind, [])
}
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
if (nextSubjects.length === 0)
return
setDraft({
fingerprint: policyFingerprint,
kind: 'specific',
subjects: nextSubjects,
})
void persistPolicy('specific', nextSubjects)
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
{environmentName(environment)}
</span>
<PermissionPicker
value={permissionKind}
disabled={isSaving || policyQuery.isLoading}
onChange={handlePermissionChange}
/>
</div>
{permissionKind === 'specific' && (
<div className="flex flex-col gap-2 pl-0 sm:pl-[152px]">
<div className="flex flex-wrap items-center gap-2">
<SubjectPicker
appId={appId}
selectedSubjects={subjects}
disabled={isSaving || policyQuery.isLoading}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
<span className="system-xs-regular text-text-tertiary">
{t('access.members.emptySelection')}
</span>
)}
</div>
{subjects.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{subjects.map(subject => (
<SubjectPill
key={subjectKey(subject)}
subject={subject}
disabled={isSaving || subjects.length <= 1}
onRemove={() => handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))}
/>
))}
</div>
)}
</div>
)}
</div>
)
}
type EndpointRowProps = {
envName: string
label: string
@ -338,9 +701,49 @@ const AccessTab: FC<AccessTabProps> = ({ 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<AccessTabProps> = ({ instanceId: appId }) => {
<div className="flex flex-col gap-3">
{deployedEnvs.map((env) => {
const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy
const kind = accessModeToPermissionKey(policy?.accessMode)
return (
<div
<EnvironmentPermissionRow
key={env.id}
className="flex flex-col gap-1.5"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
{environmentName(env)}
</span>
<PermissionPicker
value={kind}
onChange={next => setEnvironmentAccessPolicy(
appId,
env.id!,
policy?.channel ?? 'webapp',
true,
permissionKeyToAccessMode(next),
policy?.version ?? 0,
)}
/>
</div>
{kind === 'specific' && (
<div className="pl-0 system-xs-regular text-text-tertiary sm:pl-[152px]">
{t('access.permission.specificUnavailable')}
</div>
)}
</div>
appId={appId}
environment={env}
summaryPolicy={policy}
onSetPolicy={setEnvironmentAccessPolicy}
/>
)
})}
</div>
@ -527,7 +910,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
</div>
<ApiKeyGenerateMenu
environments={deployedEnvs}
onGenerate={environmentId => generateApiKey(appId, environmentId)}
onGenerate={handleGenerateApiKey}
/>
</div>
{visibleCreatedApiToken && (
@ -573,7 +956,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
<ApiKeyRow
key={apiKey.id}
apiKey={apiKey}
onRevoke={() => revokeApiKey(appId, apiKey.environmentId!, apiKey.id!)}
onRevoke={() => handleRevokeApiKey(apiKey.environmentId!, apiKey.id!)}
/>
)
})}

View File

@ -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<InstanceDetailProps> = ({ 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],

View File

@ -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<AccessOverviewRowProps> = ({ 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<OverviewTabProps> = ({ instanceId, onSwitchTab }) => {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation()
@ -83,16 +92,17 @@ const OverviewTab: FC<OverviewTabProps> = ({ 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<OverviewTabProps> = ({ instanceId, onSwitchTab }) => {
<div className="flex flex-col gap-5 p-6">
<Section title={t('overview.basicInfo')}>
<div className="flex flex-col divide-y divide-divider-subtle">
<InfoRow label={t('overview.name')} value={app.name} />
<InfoRow label={t('overview.description')} value={app.description ?? t('overview.emptyValue')} />
<InfoRow label={t('overview.name')} value={overviewApp?.name ?? app.name} />
<InfoRow label={t('overview.description')} value={overviewApp?.description ?? app.description ?? t('overview.emptyValue')} />
<InfoRow label={t('overview.sourceApp')} value={app.name} />
<InfoRow label={t('overview.appMode')} value={appModeLabel} />
</div>
@ -131,15 +141,13 @@ const OverviewTab: FC<OverviewTabProps> = ({ instanceId, onSwitchTab }) => {
: (
<div className="flex flex-col divide-y divide-divider-subtle">
{deployments.map((row) => {
const status = deploymentStatus(row)
const status = overviewDeploymentStatus(row.status)
return (
<div key={row.environment?.id} className="flex items-center justify-between gap-3 py-2">
<div key={row.environmentId} className="flex items-center justify-between gap-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">{environmentName(row.environment)}</span>
<span className="system-sm-medium text-text-primary">{row.environmentName || row.environmentId}</span>
<span className="system-xs-regular text-text-tertiary">
{releaseLabel(row.observedRuntime?.release || row.pendingDeployment?.release)}
{' · '}
{formatDate(row.instance?.lastDeployedAt || row.instance?.lastReadyAt)}
{row.releaseDisplayId || row.releaseId || t('overview.emptyValue')}
</span>
</div>
<StatusBadge status={status} />
@ -162,18 +170,18 @@ const OverviewTab: FC<OverviewTabProps> = ({ instanceId, onSwitchTab }) => {
<div className="flex flex-col divide-y divide-divider-subtle">
<AccessOverviewRow
label={t('overview.webapp')}
enabled={appData?.accessConfig.webapp?.enabled ?? false}
enabled={overview?.access?.webapp?.enabled ?? false}
hint={webappAccessUrl || t('overview.notConfigured')}
/>
<AccessOverviewRow
label={t('overview.cli')}
enabled={appData?.accessConfig.cli?.enabled ?? false}
enabled={overview?.access?.cli?.enabled ?? false}
hint={cliUrl ?? t('overview.notConfigured')}
/>
<AccessOverviewRow
label={t('overview.api')}
enabled={appData?.accessConfig.developerApi?.enabled ?? false}
hint={appData?.accessConfig.developerApi?.enabled
enabled={overview?.access?.api?.enabled ?? false}
hint={overview?.access?.api?.enabled
? t('overview.apiKeysCount', { count: apiKeysCount })
: t('overview.notConfigured')}
/>

View File

@ -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<void>
}
@ -223,8 +224,8 @@ export const useDeploymentsStore = create<DeploymentsState>((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)
},
}))

View File

@ -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,
}
}

View File

@ -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",

View File

@ -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": "撤销",