mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
tweaks
This commit is contained in:
parent
111483c73a
commit
af6aac3094
@ -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!)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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')}
|
||||
/>
|
||||
|
||||
@ -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)
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "撤销",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user