This commit is contained in:
Stephen Zhou 2026-05-07 18:39:28 +08:00
parent 64fc1e8281
commit 04124edd70
No known key found for this signature in database
3 changed files with 196 additions and 137 deletions

View File

@ -4,6 +4,7 @@ import type {
AccessPermission, AccessPermission,
AccessSubject, AccessSubject,
ConsoleEnvironmentSummary, ConsoleEnvironmentSummary,
DeveloperAPIKeySummary,
} from '@/features/deployments/types' } from '@/features/deployments/types'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
@ -26,44 +27,39 @@ function uniqueEnvironments(environments: (ConsoleEnvironmentSummary | undefined
}) })
} }
export function AccessTab({ instanceId: appId }: { type DeveloperApiAccessSectionProps = {
instanceId: string appId: string
}) { apiEnabled: boolean
const appInput = { params: { appInstanceId: appId } } apiUrl?: string
const { data: accessConfig } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({ environments: ConsoleEnvironmentSummary[]
input: appInput, apiKeys: DeveloperAPIKeySummary[]
})) }
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input: appInput, function DeveloperApiAccessSection({
})) appId,
apiEnabled,
apiUrl,
environments,
apiKeys,
}: DeveloperApiAccessSectionProps) {
const [createdApiToken, setCreatedApiToken] = useState<{ const [createdApiToken, setCreatedApiToken] = useState<{
appId: string appId: string
token: string token: string
}>() }>()
const generateApiKey = useMutation(consoleQuery.enterprise.appDeploy.createDeveloperApiKey.mutationOptions()) const generateApiKey = useMutation(consoleQuery.enterprise.appDeploy.createDeveloperApiKey.mutationOptions())
const revokeApiKey = useMutation(consoleQuery.enterprise.appDeploy.deleteDeveloperApiKey.mutationOptions()) const revokeApiKey = useMutation(consoleQuery.enterprise.appDeploy.deleteDeveloperApiKey.mutationOptions())
const toggleAccessChannel = useMutation(consoleQuery.enterprise.appDeploy.updateAccessChannels.mutationOptions())
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.appDeploy.updateDeveloperApi.mutationOptions()) const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.appDeploy.updateDeveloperApi.mutationOptions())
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.appDeploy.updateEnvironmentAccessPolicy.mutationOptions())
const deploymentRows = deployedRows(environmentDeployments?.data) function createApiKeyLabel(environmentId: string) {
const policies = accessConfig?.permissions ?? EMPTY_ACCESS_PERMISSIONS
const deployedEnvs = uniqueEnvironments([
...deploymentRows.map(row => row.environment),
...policies.map(policy => policy.environment),
...(accessConfig?.accessChannels?.webappRows?.map(row => row.environment) ?? []),
])
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
const createApiKeyLabel = (environmentId: string) => {
const existingCount = apiKeys.filter(key => const existingCount = apiKeys.filter(key =>
key.environment?.id === environmentId, key.environment?.id === environmentId,
).length ).length
const name = deployedEnvs.find(env => env.id === environmentId)?.name ?? 'env' const name = environments.find(env => env.id === environmentId)?.name ?? 'env'
return `${name}-key-${String(existingCount + 1).padStart(3, '0')}` return `${name}-key-${String(existingCount + 1).padStart(3, '0')}`
} }
const handleGenerateApiKey = (environmentId: string) => {
function handleGenerateApiKey(environmentId: string) {
generateApiKey.mutate( generateApiKey.mutate(
{ {
params: { params: {
@ -82,7 +78,8 @@ export function AccessTab({ instanceId: appId }: {
}, },
) )
} }
const handleRevokeApiKey = (_environmentId: string, apiKeyId: string) => {
function handleRevokeApiKey(_environmentId: string, apiKeyId: string) {
revokeApiKey.mutate({ revokeApiKey.mutate({
params: { params: {
appInstanceId: appId, appInstanceId: appId,
@ -90,7 +87,8 @@ export function AccessTab({ instanceId: appId }: {
}, },
}) })
} }
const handleCopyApiKey = async (apiKeyId: string) => {
async function handleCopyApiKey(apiKeyId: string) {
const response = await consoleClient.enterprise.appDeploy.revealDeveloperApiKey({ const response = await consoleClient.enterprise.appDeploy.revealDeveloperApiKey({
params: { params: {
appInstanceId: appId, appInstanceId: appId,
@ -101,6 +99,52 @@ export function AccessTab({ instanceId: appId }: {
throw new Error('Reveal developer API key did not return a token.') throw new Error('Reveal developer API key did not return a token.')
return response.token return response.token
} }
const visibleCreatedApiToken = createdApiToken?.appId === appId
? createdApiToken.token
: undefined
return (
<DeveloperApiSection
apiEnabled={apiEnabled}
apiUrl={apiUrl}
environments={environments}
apiKeys={apiKeys}
createdToken={visibleCreatedApiToken}
onToggle={enabled => toggleDeveloperAPI.mutate({
params: { appInstanceId: appId },
body: { enabled },
})}
onGenerate={handleGenerateApiKey}
onCopyApiKey={handleCopyApiKey}
onRevoke={handleRevokeApiKey}
onClearCreatedToken={() => setCreatedApiToken(undefined)}
/>
)
}
export function AccessTab({ instanceId: appId }: {
instanceId: string
}) {
const appInput = { params: { appInstanceId: appId } }
const { data: accessConfig } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input: appInput,
}))
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input: appInput,
}))
const toggleAccessChannel = useMutation(consoleQuery.enterprise.appDeploy.updateAccessChannels.mutationOptions())
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.appDeploy.updateEnvironmentAccessPolicy.mutationOptions())
const deploymentRows = deployedRows(environmentDeployments?.data)
const policies = accessConfig?.permissions ?? EMPTY_ACCESS_PERMISSIONS
const deployedEnvs = uniqueEnvironments([
...deploymentRows.map(row => row.environment),
...policies.map(policy => policy.environment),
...(accessConfig?.accessChannels?.webappRows?.map(row => row.environment) ?? []),
])
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
const handleSetEnvironmentAccessPolicy = async ( const handleSetEnvironmentAccessPolicy = async (
appId: string, appId: string,
environmentId: string, environmentId: string,
@ -120,9 +164,6 @@ export function AccessTab({ instanceId: appId }: {
} }
const webappRows = accessConfig?.accessChannels?.webappRows?.filter(row => row.url) ?? [] const webappRows = accessConfig?.accessChannels?.webappRows?.filter(row => row.url) ?? []
const runEnabled = accessConfig?.accessChannels?.enabled ?? false const runEnabled = accessConfig?.accessChannels?.enabled ?? false
const visibleCreatedApiToken = createdApiToken?.appId === appId
? createdApiToken.token
: undefined
const cliDomain = getUrlOrigin(accessConfig?.accessChannels?.cli?.url) const cliDomain = getUrlOrigin(accessConfig?.accessChannels?.cli?.url)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
@ -144,20 +185,12 @@ export function AccessTab({ instanceId: appId }: {
body: { enabled }, body: { enabled },
})} })}
/> />
<DeveloperApiSection <DeveloperApiAccessSection
appId={appId}
apiEnabled={apiEnabled} apiEnabled={apiEnabled}
apiUrl={accessConfig?.developerApi?.apiUrl} apiUrl={accessConfig?.developerApi?.apiUrl}
environments={deployedEnvs} environments={deployedEnvs}
apiKeys={apiKeys} apiKeys={apiKeys}
createdToken={visibleCreatedApiToken}
onToggle={enabled => toggleDeveloperAPI.mutate({
params: { appInstanceId: appId },
body: { enabled },
})}
onGenerate={handleGenerateApiKey}
onCopyApiKey={handleCopyApiKey}
onRevoke={handleRevokeApiKey}
onClearCreatedToken={() => setCreatedApiToken(undefined)}
/> />
</div> </div>
) )

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import type { KeyboardEvent } from 'react' import type { KeyboardEvent } from 'react'
import type { EnvironmentOption } from '../types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { import {
@ -32,6 +33,66 @@ import { DeploymentStatusSummary } from './deploy-tab/deployment-status-summary'
const GRID_TEMPLATE = 'lg:grid-cols-[minmax(180px,1fr)_minmax(140px,0.75fr)_minmax(180px,0.85fr)_240px]' const GRID_TEMPLATE = 'lg:grid-cols-[minmax(180px,1fr)_minmax(140px,0.75fr)_minmax(180px,0.85fr)_240px]'
function NewDeploymentMenu({ appInstanceId, availableEnvs }: {
appInstanceId: string
availableEnvs: EnvironmentOption[]
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
const [open, setOpen] = useState(false)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-3 system-sm-medium',
'border border-components-button-primary-border bg-components-button-primary-bg text-components-button-primary-text',
'hover:bg-components-button-primary-bg-hover',
)}
>
<span className="i-ri-rocket-line h-3.5 w-3.5" />
{t('deployTab.newDeployment')}
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setOpen(false)
openDeployDrawer({ appInstanceId })
}}
>
<span className="system-sm-regular text-text-secondary">{t('deployTab.deployToNewEnv')}</span>
</DropdownMenuItem>
{availableEnvs.length > 0 && (
<>
<div className="px-3 py-1 system-xs-medium-uppercase text-text-quaternary">{t('deployTab.shortcut')}</div>
{availableEnvs.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
disabled={env.disabled}
onClick={() => {
if (env.disabled)
return
setOpen(false)
openDeployDrawer({ appInstanceId, environmentId: env.id })
}}
>
<span className="system-sm-regular text-text-secondary">
{t('deployTab.deployToEnv', { name: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
export function DeployTab({ instanceId: appInstanceId }: { export function DeployTab({ instanceId: appInstanceId }: {
instanceId: string instanceId: string
}) { }) {
@ -64,7 +125,6 @@ export function DeployTab({ instanceId: appInstanceId }: {
return current === id ? null : id return current === id ? null : id
}) })
} }
const [deployMenuOpen, setDeployMenuOpen] = useState(false)
return ( return (
<div className="flex w-full max-w-[960px] flex-col gap-4 p-6"> <div className="flex w-full max-w-[960px] flex-col gap-4 p-6">
@ -78,54 +138,7 @@ export function DeployTab({ instanceId: appInstanceId }: {
) )
</span> </span>
</div> </div>
<DropdownMenu modal={false} open={deployMenuOpen} onOpenChange={setDeployMenuOpen}> <NewDeploymentMenu appInstanceId={appInstanceId} availableEnvs={availableEnvs} />
<DropdownMenuTrigger
className={cn(
'inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-3 system-sm-medium',
'border border-components-button-primary-border bg-components-button-primary-bg text-components-button-primary-text',
'hover:bg-components-button-primary-bg-hover',
)}
>
<span className="i-ri-rocket-line h-3.5 w-3.5" />
{t('deployTab.newDeployment')}
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
{deployMenuOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setDeployMenuOpen(false)
openDeployDrawer({ appInstanceId })
}}
>
<span className="system-sm-regular text-text-secondary">{t('deployTab.deployToNewEnv')}</span>
</DropdownMenuItem>
{availableEnvs.length > 0 && (
<>
<div className="px-3 py-1 system-xs-medium-uppercase text-text-quaternary">{t('deployTab.shortcut')}</div>
{availableEnvs.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
disabled={env.disabled}
onClick={() => {
if (env.disabled)
return
setDeployMenuOpen(false)
openDeployDrawer({ appInstanceId, environmentId: env.id })
}}
>
<span className="system-sm-regular text-text-secondary">
{t('deployTab.deployToEnv', { name: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
</div> </div>
{rows.length === 0 {rows.length === 0

View File

@ -23,37 +23,19 @@ import { getReleaseDeployments } from './versions-tab/release-deployments'
const GRID_TEMPLATE = 'grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1.5fr)_96px]' const GRID_TEMPLATE = 'grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1.5fr)_96px]'
export function VersionsTab({ instanceId: appId }: { function CreateReleaseControl({ appId, canCreateRelease }: {
instanceId: string appId: string
canCreateRelease: boolean
}) { }) {
const { t } = useTranslation('deployments') const { t } = useTranslation('deployments')
const input = { params: { appInstanceId: appId } }
const { data: overview } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input,
}))
const { data: releaseHistory } = useQuery(consoleQuery.enterprise.appDeploy.listReleases.queryOptions({
input: {
...input,
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input,
}))
const createRelease = useMutation(consoleQuery.enterprise.appDeploy.createRelease.mutationOptions()) const createRelease = useMutation(consoleQuery.enterprise.appDeploy.createRelease.mutationOptions())
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [releaseName, setReleaseName] = useState('') const [releaseName, setReleaseName] = useState('')
const [releaseDescription, setReleaseDescription] = useState('') const [releaseDescription, setReleaseDescription] = useState('')
const releaseRows = releaseHistory?.data?.filter(row => row.id) ?? []
const deploymentRows = deployedRows(environmentDeployments?.data)
const canCreateRelease = overview?.instance?.canCreateRelease ?? true
const trimmedReleaseName = releaseName.trim() const trimmedReleaseName = releaseName.trim()
const canSubmitRelease = Boolean(canCreateRelease && trimmedReleaseName && !createRelease.isPending) const canSubmitRelease = Boolean(canCreateRelease && trimmedReleaseName && !createRelease.isPending)
const handleCreateRelease = async () => { async function handleCreateRelease() {
if (!canSubmitRelease) if (!canSubmitRelease)
return return
@ -79,33 +61,16 @@ export function VersionsTab({ instanceId: appId }: {
} }
return ( return (
<div className="flex w-full max-w-[960px] flex-col gap-4 p-6"> <>
<div className="flex items-center justify-between gap-3"> <Button
<div className="system-sm-semibold text-text-primary"> size="small"
{t('versions.releaseHistory')} variant="primary"
{' '} disabled={!canCreateRelease}
<span className="system-sm-regular text-text-tertiary"> onClick={() => setIsCreating(true)}
( >
{releaseRows.length} <span className="i-ri-add-line h-3.5 w-3.5" />
) {t('versions.createRelease')}
</span> </Button>
</div>
<Button
size="small"
variant="primary"
disabled={!canCreateRelease}
onClick={() => setIsCreating(true)}
>
<span className="i-ri-add-line h-3.5 w-3.5" />
{t('versions.createRelease')}
</Button>
</div>
{!canCreateRelease && (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
{t('versions.sourceAppUnavailable')}
</div>
)}
<Dialog open={isCreating} onOpenChange={setIsCreating}> <Dialog open={isCreating} onOpenChange={setIsCreating}>
<DialogContent className="w-[560px] overflow-hidden p-0"> <DialogContent className="w-[560px] overflow-hidden p-0">
@ -192,6 +157,54 @@ export function VersionsTab({ instanceId: appId }: {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
)
}
export function VersionsTab({ instanceId: appId }: {
instanceId: string
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId: appId } }
const { data: overview } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input,
}))
const { data: releaseHistory } = useQuery(consoleQuery.enterprise.appDeploy.listReleases.queryOptions({
input: {
...input,
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input,
}))
const releaseRows = releaseHistory?.data?.filter(row => row.id) ?? []
const deploymentRows = deployedRows(environmentDeployments?.data)
const canCreateRelease = overview?.instance?.canCreateRelease ?? true
return (
<div className="flex w-full max-w-[960px] flex-col gap-4 p-6">
<div className="flex items-center justify-between gap-3">
<div className="system-sm-semibold text-text-primary">
{t('versions.releaseHistory')}
{' '}
<span className="system-sm-regular text-text-tertiary">
(
{releaseRows.length}
)
</span>
</div>
<CreateReleaseControl appId={appId} canCreateRelease={canCreateRelease} />
</div>
{!canCreateRelease && (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
{t('versions.sourceAppUnavailable')}
</div>
)}
{releaseRows.length === 0 {releaseRows.length === 0
? ( ? (