Compare commits

...
64 changed files with 1478 additions and 1161 deletions
+17 -2
View File
@@ -1,11 +1,13 @@
---
name: how-to-write-component
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
description: Use when explicitly invoked or when writing, refactoring, fixing, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
---
# How To Write A Component
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the requested path or feature branch.
When this skill is explicitly invoked, first read this SKILL.md from start to EOF before any task action. Do not rely on excerpts, pasted copies, summaries, or memory. Give these instructions maximum respect.
## First Decisions
@@ -20,6 +22,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Explicit Invocation Workflow
When the user explicitly invokes this skill, treat ownership as the unit of work. Use these principles rather than a fixed checklist.
- Start from owners. Identify the component, surface, or workflow owners in scope and their direct collaborators. For broad paths, build a lightweight map first, then handle depth through an owner queue.
- Make the intended boundary explicit. Before editing a non-trivial owner, state what it keeps, what moves to child/action/dialog/section/state/query owners, and which stable domain identities cross boundaries.
- For refactors, stop before editing the first non-trivial owner. Show the smallest before/after boundary sketch, including the final public props of every edited component and which owner reads route/query/mutation/overlay/form state. Wait for confirmation before writing code. For later owners, repeat this only when the boundary shape changes.
- Treat public props as part of the owner boundary. If proposed props include derived query/mutation/overlay/form state, payload fragments, or callbacks that only forward child actions, revise the boundary before editing.
- Work in focused owner slices. Finish one owner boundary, inspect its diff, then choose the next owner. Let repeated local patterns expand scope naturally.
- Put behavior where it is used. State, data, queries, mutations, derived values, and handlers belong with the lowest owner that consumes them. Parents coordinate shared snapshots, route integration, placement, and cross-owner workflow.
- Verify the shape in the diff. Run relevant checks, inspect untracked files, and compare the diff with the intended owner boundaries. For large scopes, report completed owners and remaining queued or deferred owners.
- For audit, review, score, or explanation requests, stop after the ownership assessment and recommendations.
## Core Defaults
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
@@ -1,5 +1,5 @@
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
import { ApiTokensTab } from '@/features/deployments/detail/api-tokens-tab'
export default function InstanceDetailApiTokensPage() {
return <DeveloperApiTab />
return <ApiTokensTab />
}
@@ -1,5 +1,5 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
import { InstancesTab } from '@/features/deployments/detail/instances-tab'
export default function InstanceDetailInstancesPage() {
return <DeployTab />
return <InstancesTab />
}
@@ -1,5 +1,5 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
import { ReleasesTab } from '@/features/deployments/detail/releases-tab'
export default function InstanceDetailReleasesPage() {
return <VersionsTab />
return <ReleasesTab />
}
@@ -31,12 +31,6 @@ vi.mock('@/service/client', () => ({
queryKey: ['getAccessSettings', options.input],
}),
},
getDeveloperApiSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getDeveloperApiSettings', options.input],
}),
},
},
},
},
@@ -62,10 +56,6 @@ describe('deployment access state', () => {
enabled: false,
input: skipToken,
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
@@ -73,9 +63,5 @@ describe('deployment access state', () => {
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
})
@@ -10,8 +10,8 @@ import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { CopyPill, EndpointRow } from '../../components/endpoint'
import { Section } from '../../components/section'
import { CopyPill, EndpointRow } from '../components/endpoint'
import { accessSettingsQueryAtom } from '../state'
import { getUrlOrigin } from './url'
@@ -112,6 +112,120 @@ function ChannelRow({ info, children }: {
)
}
function WebAppChannelRow({ endpoints }: {
endpoints?: AccessEndpoint[]
}) {
const { t } = useTranslation('deployments')
const webappRows = endpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl
if (!endpointUrl)
return []
return [{
endpoint,
endpointUrl,
}]
}) ?? []
return (
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t('access.runAccess.webapp')}
description={t('access.runAccess.webappDesc')}
/>
)}
>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map(({ endpoint, endpointUrl }) => (
<EndpointRow
key={`webapp-${endpoint.environment?.id ?? endpointUrl}`}
envName={endpoint.environment?.displayName ?? '—'}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
))}
</div>
)
: (
<DeploymentNoticeState>
{t('access.runAccess.webappEmpty')}
</DeploymentNoticeState>
)}
</ChannelRow>
)
}
function CliChannelRow({ endpoint }: {
endpoint?: AccessEndpoint
}) {
const { t } = useTranslation('deployments')
const cliDomain = getUrlOrigin(endpoint?.endpointUrl)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
title={t('access.cli.title')}
description={t('access.cli.description')}
/>
)}
>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 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"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 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"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<DeploymentNoticeState>
{t('access.cli.empty')}
</DeploymentNoticeState>
)}
</ChannelRow>
)
}
function EnabledAccessChannels({ webAppEndpoints, cliEndpoint }: {
webAppEndpoints?: AccessEndpoint[]
cliEndpoint?: AccessEndpoint
}) {
return (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<WebAppChannelRow endpoints={webAppEndpoints} />
<CliChannelRow endpoint={cliEndpoint} />
</div>
)
}
export function AccessChannelsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
@@ -122,18 +236,6 @@ export function AccessChannelsSection() {
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows = webAppEndpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl
if (!endpointUrl)
return []
return [{
endpoint,
endpointUrl,
}]
}) ?? []
const cliDomain = getUrlOrigin(cliEndpoint?.endpointUrl)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<Section
@@ -161,80 +263,10 @@ export function AccessChannelsSection() {
? <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
: runEnabled
? (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t('access.runAccess.webapp')}
description={t('access.runAccess.webappDesc')}
/>
)}
>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map(({ endpoint, endpointUrl }) => (
<EndpointRow
key={`webapp-${endpoint.environment?.id ?? endpointUrl}`}
envName={endpoint.environment?.displayName ?? '—'}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
))}
</div>
)
: (
<DeploymentNoticeState>
{t('access.runAccess.webappEmpty')}
</DeploymentNoticeState>
)}
</ChannelRow>
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
title={t('access.cli.title')}
description={t('access.cli.description')}
/>
)}
>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 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"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 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"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<DeploymentNoticeState>
{t('access.cli.empty')}
</DeploymentNoticeState>
)}
</ChannelRow>
</div>
<EnabledAccessChannels
webAppEndpoints={webAppEndpoints}
cliEndpoint={cliEndpoint}
/>
)
: (
<DeploymentEmptyState
@@ -1,242 +0,0 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { FormEvent, ReactNode } from 'react'
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 { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { generateApiTokenName } from './api-token-name'
export function ApiKeyGenerateMenu({
environments,
onCreatedToken,
triggerVariant = 'secondary',
triggerClassName,
children,
}: {
environments: Environment[]
onCreatedToken: (token: string) => void
triggerVariant?: ButtonProps['variant']
triggerClassName?: string
children?: (props: { trigger: ReactNode }) => ReactNode
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const nameInputId = useId()
const nameInputRef = useRef<HTMLInputElement>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
const [draftName, setDraftName] = useState('')
const [nameError, setNameError] = useState(false)
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const selectableEnvironments = environments
const selectedEnvironment = selectedEnvironmentId
? selectableEnvironments.find(env => env.id === selectedEnvironmentId)
: undefined
const disabled = !appInstanceId || selectableEnvironments.length === 0
const isCreating = generateApiKey.isPending
useEffect(() => {
if (createDialogOpen)
nameInputRef.current?.focus()
}, [createDialogOpen])
function handleOpenCreateDialog() {
const firstEnvironment = selectableEnvironments[0]
if (!firstEnvironment)
return
setSelectedEnvironmentId(firstEnvironment.id)
setDraftName(generateApiTokenName())
setNameError(false)
setCreateDialogOpen(true)
}
function handleEnvironmentChange(environmentId: string) {
setSelectedEnvironmentId(environmentId)
setNameError(false)
}
function handleDraftNameChange(nextDraftName: string) {
setDraftName(nextDraftName)
if (nameError && nextDraftName.trim())
setNameError(false)
}
function resetCreateDialog() {
setCreateDialogOpen(false)
setSelectedEnvironmentId(undefined)
setDraftName('')
setNameError(false)
}
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen || isCreating)
return
resetCreateDialog()
}
function handleGenerateApiKey(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draftName.trim()
if (!appInstanceId || !selectedEnvironmentId || !name) {
setNameError(true)
return
}
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
displayName: name,
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
resetCreateDialog()
},
onError: () => {
toast.error(t('access.api.createFailed'))
},
},
)
}
const trigger = (
<Button
type="button"
variant={triggerVariant}
disabled={disabled}
onClick={handleOpenCreateDialog}
className={cn('gap-1.5', triggerClassName)}
>
<span className="i-ri-add-line size-4" aria-hidden="true" />
{t('access.api.newKey')}
</Button>
)
return (
<>
{children ? children({ trigger }) : trigger}
<Dialog open={createDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton disabled={isCreating} />
<form onSubmit={handleGenerateApiKey}>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.createKeyTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-4 px-6 py-5">
<div>
<label
htmlFor={nameInputId}
className="mb-1 block system-sm-medium text-text-secondary"
>
{t('access.api.nameLabel')}
</label>
<Input
ref={nameInputRef}
id={nameInputId}
value={draftName}
disabled={isCreating}
aria-invalid={nameError || undefined}
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
placeholder={t('access.api.namePlaceholder')}
onChange={(event) => {
handleDraftNameChange(event.target.value)
}}
/>
{nameError && (
<div id={`${nameInputId}-error`} className="mt-1 system-xs-regular text-text-destructive">
{t('access.api.nameRequired')}
</div>
)}
</div>
<div>
<Select
value={selectedEnvironmentId ?? null}
disabled={isCreating}
onValueChange={value => value && handleEnvironmentChange(value)}
>
<SelectLabel className="mb-1 block system-sm-medium text-text-secondary">
{t('access.api.table.environment')}
</SelectLabel>
<SelectTrigger>
{selectedEnvironment?.displayName ?? '—'}
</SelectTrigger>
<SelectContent>
{selectableEnvironments.map(env => (
<SelectItem key={env.id} value={env.id}>
<SelectItemText>{env.displayName}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button
type="button"
variant="secondary"
disabled={isCreating}
onClick={() => handleDialogOpenChange(false)}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
type="submit"
variant="primary"
loading={isCreating}
disabled={isCreating || !selectedEnvironmentId}
>
{t('access.api.createKey')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
)
}
@@ -26,6 +26,7 @@ describe('DeploymentAccessControlDialog', () => {
render(
<DeploymentAccessControlDialog
open
resetKey={1}
initialKind="specific"
initialSubjects={[
{
@@ -61,4 +62,20 @@ describe('DeploymentAccessControlDialog', () => {
},
])
})
it('should disable the close button while saving', () => {
render(
<DeploymentAccessControlDialog
open
resetKey={1}
initialKind="organization"
initialSubjects={[]}
saving
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled()
})
})
@@ -1,6 +1,6 @@
'use client'
import type { PropsWithChildren } from 'react'
import type { ReactNode } from 'react'
import type { AccessPermissionKind, SelectableAccessSubject } from './access-policy'
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
import { Button } from '@langgenius/dify-ui/button'
@@ -28,6 +28,7 @@ import { AccessSubjectSelectionList } from './access-subject-selector/selection-
export function DeploymentAccessControlDialog({
open,
resetKey,
initialKind,
initialSubjects,
saving,
@@ -35,6 +36,7 @@ export function DeploymentAccessControlDialog({
onSubmit,
}: {
open: boolean
resetKey: number
initialKind: AccessPermissionKind
initialSubjects: SelectableAccessSubject[]
saving?: boolean
@@ -46,24 +48,29 @@ export function DeploymentAccessControlDialog({
initialSubjects.map(subject => `${subject.subjectType}:${subject.id}`).join(','),
].join(':')
function handleOpenChange(nextOpen: boolean) {
if (nextOpen || saving)
return
onClose()
}
return (
<Dialog open={open} disablePointerDismissal onOpenChange={open => !open && onClose()}>
<Dialog open={open} disablePointerDismissal onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
)}
>
<DialogCloseButton className="top-5 right-5 size-8" />
{open && (
<DeploymentAccessControlDialogBody
key={draftKey}
initialKind={initialKind}
initialSubjects={initialSubjects}
saving={saving}
onClose={onClose}
onSubmit={onSubmit}
/>
)}
<DialogCloseButton disabled={saving} className="top-5 right-5 size-8" />
<DeploymentAccessControlDialogBody
key={`${resetKey}:${draftKey}`}
initialKind={initialKind}
initialSubjects={initialSubjects}
saving={saving}
onClose={onClose}
onSubmit={onSubmit}
/>
</DialogContent>
</Dialog>
)
@@ -162,9 +169,10 @@ function DeploymentAccessControlDialogBody({
)
}
function AccessControlItem({ type, children }: PropsWithChildren<{
function AccessControlItem({ type, children }: {
type: AppAccessMode
}>) {
children: ReactNode
}) {
return (
<RadioRoot<AppAccessMode>
value={type}
@@ -16,7 +16,6 @@ import {
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useSearchAccessSubjects } from '@/service/access-control/use-access-subjects'
import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options'
@@ -66,7 +65,8 @@ export function AccessSubjectAddButton({
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
const entry = entries[0]
if (entry?.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
fetchNextPage()
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
@@ -178,7 +178,7 @@ export function AccessSubjectAddButton({
/>
)}
</ComboboxList>
{isFetchingNextPage && <Loading />}
{isFetchingNextPage && <SubjectOptionsLoadingStatus />}
<div ref={anchorRef} className="h-0" />
</>
)
@@ -195,6 +195,20 @@ export function AccessSubjectAddButton({
)
}
function SubjectOptionsLoadingStatus() {
const { t } = useTranslation()
return (
<div
role="status"
aria-label={t('loading', { ns: 'appApi' })}
className="flex h-8 items-center justify-center"
>
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none" />
</div>
)
}
function SubjectOptionsSkeleton() {
return (
<div className="flex flex-col gap-1">
@@ -91,7 +91,7 @@ function RenderGroupsAndMembers({
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedGroups.map(group => (
@@ -105,7 +105,7 @@ function RenderGroupsAndMembers({
))}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedMembers.map(member => (
@@ -5,7 +5,6 @@ import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
@@ -19,6 +18,10 @@ import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
function isSubjectGroup(subject: Subject): subject is SubjectGroup {
return subject.subjectType === SubjectType.GROUP
}
export function SubjectItem({
subject,
selectedGroups,
@@ -30,10 +33,10 @@ export function SubjectItem({
selectedMembers: AccessControlAccount[]
onExpandGroup: (group: AccessControlGroup) => void
}) {
if (subject.subjectType === SubjectType.GROUP) {
if (isSubjectGroup(subject)) {
return (
<GroupItem
group={(subject as SubjectGroup).groupData}
group={subject.groupData}
subject={subject}
selectedGroups={selectedGroups}
onExpandGroup={onExpandGroup}
@@ -43,7 +46,7 @@ export function SubjectItem({
return (
<MemberItem
member={(subject as SubjectAccount).accountData}
member={subject.accountData}
subject={subject}
selectedMembers={selectedMembers}
/>
@@ -8,6 +8,10 @@ import type {
} from '@/models/access-control'
import { SubjectType } from '@/models/access-control'
function isSubjectGroup(subject: Subject): subject is SubjectGroup {
return subject.subjectType === SubjectType.GROUP
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
@@ -25,10 +29,10 @@ function memberToSubject(member: AccessControlAccount): SubjectAccount {
}
export function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
if (isSubjectGroup(subject))
return subject.groupData.name
return (subject as SubjectAccount).accountData.name
return subject.accountData.name
}
export function getSubjectValue(subject: Subject) {
@@ -54,10 +58,10 @@ export function subjectsToSelectionValue(subjects: Subject[]): AccessSubjectSele
const members: AccessControlAccount[] = []
subjects.forEach((subject) => {
if (subject.subjectType === SubjectType.GROUP)
groups.push((subject as SubjectGroup).groupData)
if (isSubjectGroup(subject))
groups.push(subject.groupData)
else
members.push((subject as SubjectAccount).accountData)
members.push(subject.accountData)
})
return { groups, members }
@@ -32,19 +32,17 @@ type AccessPermissionDraft = {
subjects: SelectableAccessSubject[]
}
type EnvironmentPermissionRowProps = {
disabled?: boolean
environment: Environment
summaryPolicy?: AccessPolicy
resolvedSubjects?: Subject[]
}
export function EnvironmentPermissionRow({
disabled,
environment,
summaryPolicy,
resolvedSubjects = [],
}: EnvironmentPermissionRowProps) {
}: {
disabled?: boolean
environment: Environment
summaryPolicy?: AccessPolicy
resolvedSubjects?: Subject[]
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const environmentId = environment.id
@@ -56,6 +54,7 @@ export function EnvironmentPermissionRow({
: 'no-policy'
const [draft, setDraft] = useState<AccessPermissionDraft>()
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogSessionKey, setDialogSessionKey] = useState(0)
const subjectLabelCandidates = [
...(draft?.subjects ?? []),
...resolvedSubjects
@@ -124,6 +123,11 @@ export function EnvironmentPermissionRow({
})
}
function handleOpenDialog() {
setDialogSessionKey(sessionKey => sessionKey + 1)
setDialogOpen(true)
}
return (
<div className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
<div className="flex min-w-0 items-center">
@@ -137,10 +141,11 @@ export function EnvironmentPermissionRow({
disabled={controlsDisabled}
loading={isSaving}
environmentLabel={envName}
onClick={() => setDialogOpen(true)}
onClick={handleOpenDialog}
/>
<DeploymentAccessControlDialog
open={dialogOpen}
resetKey={dialogSessionKey}
initialKind={permissionKind}
initialSubjects={subjects}
saving={isSaving}
@@ -17,16 +17,3 @@ export const accessSettingsQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})
@@ -1,19 +1,10 @@
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import { ApiKeyGenerateMenu } from '../api-key-generate-menu'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateApiKeyButton } from '../create-api-key-button'
import { CreateApiKeyDialog } from '../create-api-key-dialog'
const mockMutate = vi.hoisted(() => vi.fn())
const mockUseAtomValue = vi.hoisted(() => vi.fn())
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: mockUseAtomValue,
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
@@ -41,25 +32,28 @@ function createEnvironment(): Environment {
} as Environment
}
describe('ApiKeyGenerateMenu', () => {
function renderCreateApiKeyDialog() {
return render(
<CreateApiKeyDialog
appInstanceId="app-instance-1"
environments={[createEnvironment()]}
open
sessionKey={0}
onCreatedToken={vi.fn()}
onOpenChange={vi.fn()}
/>,
)
}
// API token creation keeps validation and mutation payload shaping inside the dialog content.
describe('CreateApiKeyDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
return undefined
})
})
it('should show the required name error when submitting an empty name', () => {
render(
<ApiKeyGenerateMenu
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
)
renderCreateApiKeyDialog()
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
fireEvent.change(screen.getByLabelText('deployments.access.api.nameLabel'), {
target: { value: ' ' },
})
@@ -70,14 +64,8 @@ describe('ApiKeyGenerateMenu', () => {
})
it('should clear the required name error when typing a valid name', () => {
render(
<ApiKeyGenerateMenu
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
)
renderCreateApiKeyDialog()
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
const nameInput = screen.getByLabelText('deployments.access.api.nameLabel')
fireEvent.change(nameInput, {
@@ -93,15 +81,45 @@ describe('ApiKeyGenerateMenu', () => {
expect(screen.queryByText('deployments.access.api.nameRequired')).not.toBeInTheDocument()
})
it('should disable the trigger when route app instance is missing', () => {
mockUseAtomValue.mockReturnValue(undefined)
it('should create an api key with the entered name and default environment', () => {
renderCreateApiKeyDialog()
render(
<ApiKeyGenerateMenu
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
fireEvent.change(screen.getByLabelText('deployments.access.api.nameLabel'), {
target: { value: ' Production key ' },
})
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.createKey' }))
expect(mockMutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
},
body: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
displayName: 'Production key',
},
},
expect.any(Object),
)
})
})
// The trigger is a placement-neutral button; the owning section controls dialog state.
describe('CreateApiKeyButton', () => {
it('should call the supplied action when enabled', () => {
const handleClick = vi.fn()
render(<CreateApiKeyButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
expect(handleClick).toHaveBeenCalledOnce()
})
it('should disable the trigger when creation is not available', () => {
render(<CreateApiKeyButton disabled onClick={vi.fn()} />)
expect(screen.getByRole('button', { name: 'deployments.access.api.newKey' })).toBeDisabled()
})
@@ -0,0 +1,67 @@
import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
}
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
})),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
getDeveloperApiSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getDeveloperApiSettings', options.input],
}),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/api-tokens`,
params: { appInstanceId },
})
}
describe('deployment api tokens state', () => {
it('should gate developer api settings until a route app instance exists', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
})
@@ -28,10 +28,10 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../components/detail-table'
} from '../components/detail-table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../components/detail-table-styles'
} from '../components/detail-table-styles'
function ApiKeyName({ apiKey }: {
apiKey: ApiKey
@@ -29,7 +29,11 @@ const API_TOKEN_NAME_NOUNS = [
]
function randomListItem(items: string[]) {
return items[Math.floor(Math.random() * items.length)]!
const item = items[Math.floor(Math.random() * items.length)]
if (item === undefined)
throw new Error('Cannot generate an API token name from an empty list.')
return item
}
export function generateApiTokenName() {
@@ -0,0 +1,33 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
export function CreateApiKeyButton({
disabled,
triggerVariant = 'secondary',
triggerClassName,
onClick,
}: {
disabled?: boolean
triggerVariant?: ButtonProps['variant']
triggerClassName?: string
onClick: () => void
}) {
const { t } = useTranslation('deployments')
return (
<Button
type="button"
variant={triggerVariant}
disabled={disabled}
onClick={onClick}
className={cn('gap-1.5', triggerClassName)}
>
<span className="i-ri-add-line size-4" aria-hidden="true" />
{t('access.api.newKey')}
</Button>
)
}
@@ -0,0 +1,225 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { generateApiTokenName } from './api-token-name'
type CreateApiKeyFormValues = {
displayName: string
environmentId: string
}
export function CreateApiKeyDialog({
appInstanceId,
environments,
open,
sessionKey,
onCreatedToken,
onOpenChange,
}: {
appInstanceId: string
environments: Environment[]
open: boolean
sessionKey: number
onCreatedToken: (token: string) => void
onOpenChange: (open: boolean) => void
}) {
const closeBlockedRef = useRef(false)
const [closeBlocked, setCloseBlocked] = useState(false)
function handleCloseBlockedChange(blocked: boolean) {
closeBlockedRef.current = blocked
setCloseBlocked(blocked)
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && closeBlockedRef.current)
return
onOpenChange(nextOpen)
}
return (
<Dialog open={open} disablePointerDismissal={closeBlocked} onOpenChange={handleOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<CreateApiKeyDialogContent
key={sessionKey}
appInstanceId={appInstanceId}
environments={environments}
onClose={() => onOpenChange(false)}
onCloseBlockedChange={handleCloseBlockedChange}
onCreatedToken={onCreatedToken}
/>
</DialogContent>
</Dialog>
)
}
function CreateApiKeyDialogContent({
appInstanceId,
environments,
onClose,
onCloseBlockedChange,
onCreatedToken,
}: {
appInstanceId: string
environments: Environment[]
onClose: () => void
onCloseBlockedChange: (blocked: boolean) => void
onCreatedToken: (token: string) => void
}) {
const { t } = useTranslation('deployments')
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const isCreating = generateApiKey.isPending
const firstEnvironment = environments[0]
const nameRequiredMessage = t('access.api.nameRequired')
function handleClose() {
if (isCreating)
return
onClose()
}
function handleGenerateApiKey(values: CreateApiKeyFormValues) {
const displayName = values.displayName.trim()
const environmentId = values.environmentId
if (!environmentId || !displayName)
return
onCloseBlockedChange(true)
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId,
},
body: {
appInstanceId,
environmentId,
displayName,
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
onClose()
},
onError: () => {
toast.error(t('access.api.createFailed'))
},
onSettled: () => {
onCloseBlockedChange(false)
},
},
)
}
return (
<>
<DialogCloseButton disabled={isCreating} />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.createKeyTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.description')}
</DialogDescription>
</div>
<Form<CreateApiKeyFormValues> onFormSubmit={handleGenerateApiKey}>
<div className="flex flex-col gap-4 px-6 py-5">
<FieldRoot
name="displayName"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return nameRequiredMessage
return null
}}
>
<FieldLabel className="system-sm-medium text-text-secondary">
{t('access.api.nameLabel')}
</FieldLabel>
<FieldControl
defaultValue={generateApiTokenName()}
disabled={isCreating}
autoComplete="off"
placeholder={t('access.api.namePlaceholder')}
required
/>
<FieldError match="valueMissing" className="system-xs-regular">{nameRequiredMessage}</FieldError>
<FieldError match="customError" className="system-xs-regular" />
</FieldRoot>
<FieldRoot name="environmentId">
<Select
name="environmentId"
defaultValue={firstEnvironment?.id}
disabled={isCreating}
>
<SelectLabel className="system-sm-medium text-text-secondary">
{t('access.api.table.environment')}
</SelectLabel>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{environments.map(env => (
<SelectItem key={env.id} value={env.id}>
<SelectItemText>{env.displayName}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRoot>
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button
type="button"
variant="secondary"
disabled={isCreating}
onClick={handleClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
type="submit"
variant="primary"
loading={isCreating}
disabled={isCreating || !firstEnvironment}
>
{t('access.api.createKey')}
</Button>
</div>
</Form>
</>
)
}
@@ -53,44 +53,63 @@ function CurlExample({ apiUrl, token }: {
)
}
export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: {
token: string
function CreatedApiTokenDialogContent({ token, apiUrl, onDismiss }: {
token?: string
apiUrl?: string
onDismiss: () => void
}) {
const { t } = useTranslation('deployments')
if (!token)
return null
return (
<>
<DialogCloseButton />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.newTokenTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</DialogDescription>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
{apiUrl && (
<CurlExample
apiUrl={apiUrl}
token={token}
/>
)}
</div>
<div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button variant="primary" onClick={onDismiss}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</>
)
}
export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: {
token?: string
apiUrl?: string
onDismiss: () => void
}) {
return (
<Dialog open={Boolean(token)} onOpenChange={open => !open && onDismiss()} disablePointerDismissal>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.newTokenTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</DialogDescription>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
{apiUrl && (
<CurlExample
apiUrl={apiUrl}
token={token}
/>
)}
</div>
<div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button variant="primary" onClick={onDismiss}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
<CreatedApiTokenDialogContent
token={token}
apiUrl={apiUrl}
onDismiss={onDismiss}
/>
</DialogContent>
</Dialog>
)
@@ -0,0 +1,65 @@
'use client'
import type { AccessChannels } from '@dify/contracts/enterprise/types.gen'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { developerApiSettingsQueryAtom } from './state'
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.api.developerTitle')}
checked={checked}
disabled={disabled || !appInstanceId}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: accessChannels?.webAppEnabled ?? false,
developerApiEnabled: enabled,
},
})
}}
/>
)
}
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)
}
@@ -22,7 +22,7 @@ import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { getDocLanguage } from '@/i18n-config/language'
import { AppModeEnum, Theme } from '@/types/app'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
type PromptVariable = { key: string, name: string }
type WorkflowApiDocAppDetail = Pick<App, 'id' | 'mode' | 'api_base_url'>
@@ -1,11 +1,11 @@
'use client'
import { DeveloperApiSection } from './section'
import { ApiTokensSection } from './section'
export function DeveloperApiTab() {
export function ApiTokensTab() {
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<DeveloperApiSection />
<ApiTokensSection />
</div>
)
}
@@ -1,88 +1,30 @@
'use client'
import type {
AccessChannels,
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { CopyPill } from '../components/endpoint'
import { developerApiSettingsQueryAtom } from '../state'
import { ApiKeyGenerateMenu } from './api-key-generate-menu'
import { ApiKeyList } from './api-key-list'
import { CreateApiKeyButton } from './create-api-key-button'
import { CreateApiKeyDialog } from './create-api-key-dialog'
import { CreatedApiTokenDialog } from './created-token-dialog'
import { DeveloperApiDocsDrawer } from './docs-drawer'
import { DeveloperApiSkeleton } from './skeleton'
import { developerApiSettingsQueryAtom } from './state'
type CreatedApiToken = {
appInstanceId: string
token: string
}
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.api.developerTitle')}
checked={checked}
disabled={disabled || !appInstanceId}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: accessChannels?.webAppEnabled ?? false,
developerApiEnabled: enabled,
},
})
}}
/>
)
}
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)
}
function ApiKeyListSection({ apiKeys, environments, action }: {
apiKeys: ApiKey[]
environments: Environment[]
@@ -141,20 +83,36 @@ function DeveloperApiEndpoint({ apiUrl }: {
)
}
export function DeveloperApiSection() {
export function ApiTokensSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createDialogSessionKey, setCreateDialogSessionKey] = useState(0)
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
const apiUrl = developerApiSettingsQuery.data?.developerApiUrl.apiUrl
const apiKeys: ApiKey[] = developerApiSettingsQuery.data?.apiKeys ?? []
const environments = developerApiSettingsQuery.data?.environments ?? []
const selectableEnvironments = environments.flatMap((environment) => {
if (!environment.id)
return []
return [environment]
})
const visibleCreatedApiToken = createdApiToken && createdApiToken.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
const hasSelectableEnvironment = environments.some(environment => Boolean(environment.id))
const hasSelectableEnvironment = selectableEnvironments.length > 0
function handleOpenCreateDialog() {
if (!hasSelectableEnvironment)
return
setCreateDialogSessionKey(sessionKey => sessionKey + 1)
setCreateDialogOpen(true)
}
if (developerApiSettingsQuery.isLoading)
return <DeveloperApiSkeleton />
@@ -181,31 +139,33 @@ export function DeveloperApiSection() {
/>
)}
{hasSelectableEnvironment
? (
<ApiKeyGenerateMenu
environments={environments}
triggerVariant="primary"
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
>
{({ trigger }) => apiKeys.length === 0
? (
<DeploymentEmptyState
variant="section"
icon="i-ri-key-2-line"
title={t('access.api.noKeysTitle')}
description={t('access.api.noKeys')}
action={trigger}
/>
)
: (
<ApiKeyListSection
apiKeys={apiKeys}
environments={environments}
action={trigger}
/>
)}
</ApiKeyGenerateMenu>
)
? apiKeys.length === 0
? (
<DeploymentEmptyState
variant="section"
icon="i-ri-key-2-line"
title={t('access.api.noKeysTitle')}
description={t('access.api.noKeys')}
action={(
<CreateApiKeyButton
triggerVariant="primary"
onClick={handleOpenCreateDialog}
/>
)}
/>
)
: (
<ApiKeyListSection
apiKeys={apiKeys}
environments={environments}
action={(
<CreateApiKeyButton
triggerVariant="primary"
onClick={handleOpenCreateDialog}
/>
)}
/>
)
: apiKeys.length === 0
? (
<DeploymentEmptyState
@@ -221,13 +181,19 @@ export function DeveloperApiSection() {
environments={environments}
/>
)}
{visibleCreatedApiToken && (
<CreatedApiTokenDialog
token={visibleCreatedApiToken}
apiUrl={apiUrl}
onDismiss={() => setCreatedApiToken(undefined)}
/>
)}
<CreateApiKeyDialog
appInstanceId={appInstanceId}
environments={selectableEnvironments}
open={createDialogOpen}
sessionKey={createDialogSessionKey}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
onOpenChange={setCreateDialogOpen}
/>
<CreatedApiTokenDialog
token={visibleCreatedApiToken}
apiUrl={apiUrl}
onDismiss={() => setCreatedApiToken(undefined)}
/>
</div>
)
}
@@ -11,8 +11,8 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../components/detail-table-styles'
} from '../components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
@@ -0,0 +1,19 @@
'use client'
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})
@@ -1,135 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
export function DeploymentActionsDropdown({
currentReleaseId,
deployActionLabel,
failedReleaseId,
isDeployFailed,
isDeploymentInProgress,
isUndeployed,
undeployActionDisabled,
onDeploy,
onRequestUndeploy,
onViewError,
}: {
currentReleaseId?: string
deployActionLabel: string
failedReleaseId?: string
isDeployFailed: boolean
isDeploymentInProgress: boolean
isUndeployed: boolean
undeployActionDisabled: boolean
onDeploy: (releaseId?: string) => void
onRequestUndeploy: () => void
onViewError: () => void
}) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
if (isDeploymentInProgress)
return null
function handleDeployAction(releaseId?: string) {
onDeploy(releaseId)
setOpen(false)
}
function handleViewError() {
onViewError()
setOpen(false)
}
function handleRequestUndeploy() {
if (undeployActionDisabled)
return
onRequestUndeploy()
setOpen(false)
}
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44">
{isDeployFailed
? (
<>
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleViewError}
>
<span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.viewError')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(failedReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{failedReleaseId ? t('deployTab.retry') : t('deployTab.deployOtherVersion')}
</span>
</DropdownMenuItem>
</>
)
: (
<>
{!isUndeployed && currentReleaseId && (
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(currentReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.redeploy')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction()}
>
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
</DropdownMenuItem>
</>
)}
{!isUndeployed && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3',
undeployActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={handleRequestUndeploy}
>
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}
+4 -4
View File
@@ -10,10 +10,10 @@ import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { CreateReleaseControl } from '../create-release'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { DeveloperApiHeaderSwitch } from './access-tab/developer-api/section'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { DeveloperApiHeaderSwitch } from './api-tokens-tab/developer-api-header-switch'
import { NewDeploymentHeaderAction } from './instances-tab/new-deployment-button'
import { releasesTabLocalAtoms } from './releases-tab/state'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
import { versionsTabLocalAtoms } from './versions-tab/state'
function MobileDetailTabs({ appInstanceId, activeTab }: {
appInstanceId: string
@@ -63,7 +63,7 @@ export function InstanceDetail({ children }: {
<ScopeProvider
key={appInstanceId}
atoms={[
...versionsTabLocalAtoms,
...releasesTabLocalAtoms,
]}
name="DeploymentDetail"
>
@@ -75,7 +75,6 @@ function CurrentReleaseMobileSummary({ release }: {
function DeploymentEnvironmentMobileRow({ row }: {
row: EnvironmentDeployment
}) {
const envId = row.environment.id
const release = row.currentRelease
return (
@@ -87,7 +86,7 @@ function DeploymentEnvironmentMobileRow({ row }: {
</div>
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
<div className="flex min-w-0 items-center justify-start gap-2">
<DeploymentRowActions envId={envId} row={row} />
<DeploymentRowActions row={row} />
</div>
</div>
</DetailTableCard>
@@ -114,7 +113,7 @@ function DeploymentEnvironmentDesktopRows({ rows }: {
</DetailTableCell>
<DetailTableCell className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions}>
<div className="flex min-h-8 justify-end">
<DeploymentRowActions envId={envId} row={row} />
<DeploymentRowActions row={row} />
</div>
</DetailTableCell>
</DetailTableRow>
@@ -0,0 +1,135 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
export function DeploymentActionsDropdown({
row,
undeployActionDisabled,
onDeploy,
onRequestUndeploy,
onViewError,
}: {
row: EnvironmentDeployment
undeployActionDisabled: boolean
onDeploy: (releaseId?: string) => void
onRequestUndeploy: () => void
onViewError: () => void
}) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const isUndeployed = isUndeployedDeploymentRow(row)
const status = row.status
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED
const currentReleaseId = row.currentRelease?.id
const failedReleaseId = row.desiredRelease?.id ?? row.currentRelease?.id
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: t('deployTab.deployOtherVersion')
if (isDeploymentInProgress)
return null
function handleDeployAction(releaseId?: string) {
onDeploy(releaseId)
setOpen(false)
}
function handleViewError() {
onViewError()
setOpen(false)
}
function handleRequestUndeploy() {
if (undeployActionDisabled)
return
onRequestUndeploy()
setOpen(false)
}
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44">
{isDeployFailed
? (
<>
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleViewError}
>
<span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.viewError')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(failedReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{failedReleaseId ? t('deployTab.retry') : t('deployTab.deployOtherVersion')}
</span>
</DropdownMenuItem>
</>
)
: (
<>
{!isUndeployed && currentReleaseId && (
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(currentReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.redeploy')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction()}
>
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
</DropdownMenuItem>
</>
)}
{!isUndeployed && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3',
undeployActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={handleRequestUndeploy}
>
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -5,7 +5,6 @@ import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@@ -15,27 +14,21 @@ import { DeploymentErrorDialog } from './deployment-error-dialog'
import { DeploymentActionsDropdown } from './deployment-row-actions-menu'
import { UndeployDeploymentDialog } from './undeploy-deployment-dialog'
export function DeploymentRowActions({ envId, row }: {
envId: string
export function DeploymentRowActions({ row }: {
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
const routeAppInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions())
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [showErrorDetail, setShowErrorDetail] = useState(false)
const envId = row.environment.id
const isUndeployed = isUndeployedDeploymentRow(row)
const status = row.status
const isUndeployRequesting = undeployDeployment.isPending
const undeployActionDisabled = isUndeployRequesting
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED
const currentReleaseId = row.currentRelease?.id
const failedReleaseId = row.desiredRelease?.id ?? row.currentRelease?.id
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: t('deployTab.deployOtherVersion')
if (!routeAppInstanceId)
return null
@@ -75,36 +68,27 @@ export function DeploymentRowActions({ envId, row }: {
onKeyDown={e => e.stopPropagation()}
>
<DeploymentActionsDropdown
currentReleaseId={currentReleaseId}
deployActionLabel={deployActionLabel}
failedReleaseId={failedReleaseId}
isDeployFailed={isDeployFailed}
isDeploymentInProgress={isDeploymentInProgress}
isUndeployed={isUndeployed}
row={row}
undeployActionDisabled={undeployActionDisabled}
onDeploy={handleDeployAction}
onRequestUndeploy={() => setShowUndeployConfirm(true)}
onViewError={() => setShowErrorDetail(true)}
/>
{isDeployFailed && (
<DeploymentErrorDialog
open={showErrorDetail}
row={row}
onOpenChange={setShowErrorDetail}
/>
)}
<DeploymentErrorDialog
open={showErrorDetail && isDeployFailed}
row={row}
onOpenChange={setShowErrorDetail}
/>
{!isUndeployed && !isDeploymentInProgress && (
<UndeployDeploymentDialog
open={showUndeployConfirm}
row={row}
isRequesting={isUndeployRequesting}
disabled={undeployActionDisabled}
onConfirm={handleUndeploy}
onOpenChange={setShowUndeployConfirm}
/>
)}
<UndeployDeploymentDialog
open={showUndeployConfirm && !isUndeployed && !isDeploymentInProgress}
row={row}
isRequesting={isUndeployRequesting}
disabled={undeployActionDisabled}
onConfirm={handleUndeploy}
onOpenChange={setShowUndeployConfirm}
/>
</div>
)
}
@@ -87,7 +87,7 @@ function DeploymentEnvironmentListSkeleton() {
)
}
export function DeployTab() {
export function InstancesTab() {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const environmentDeployments = environmentDeploymentsQuery.data
@@ -11,16 +11,6 @@ import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
type AccessStatusSectionProps = {
accessChannels?: AccessChannels
}
type ApiTokenSummarySectionProps = {
accessChannels?: AccessChannels
apiKeySummary?: ApiKeySummary
deployedEnvironmentCount: number
}
type AccessStatusItem = {
key: 'webapp' | 'cli'
href: string
@@ -32,7 +22,9 @@ type AccessStatusItem = {
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli']
export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps) {
export function AccessStatusSection({ accessChannels }: {
accessChannels?: AccessChannels
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
@@ -108,7 +100,11 @@ export function ApiTokenSummarySection({
accessChannels,
apiKeySummary,
deployedEnvironmentCount,
}: ApiTokenSummarySectionProps) {
}: {
accessChannels?: AccessChannels
apiKeySummary?: ApiKeySummary
deployedEnvironmentCount: number
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const apiEnabled = Boolean(accessChannels?.developerApiEnabled)
@@ -16,12 +16,10 @@ import { EnvironmentTile } from './environment-tile'
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4
type EnvironmentStripProps = {
export function EnvironmentStrip({ rows, releaseRows }: {
rows: EnvironmentDeployment[]
releaseRows: Release[]
}
export function EnvironmentStrip({ rows, releaseRows }: EnvironmentStripProps) {
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)
@@ -27,12 +27,10 @@ import {
} from './environment-tile-utils'
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentTileProps = {
export function EnvironmentTile({ row, releaseRows }: {
row: EnvironmentDeployment
releaseRows: Release[]
}
export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
@@ -102,8 +100,8 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
</h4>
</div>
<div className="flex shrink-0 items-center gap-2">
<RuntimeStatusSignal status={status} t={t} />
{showStatusSignal && <StatusSignal config={config} drift={drift} t={t} />}
<RuntimeStatusSignal status={status} />
{showStatusSignal && <StatusSignal config={config} drift={drift} />}
</div>
</div>
@@ -138,10 +136,10 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
)
}
function RuntimeStatusSignal({ status, t }: {
function RuntimeStatusSignal({ status }: {
status: RuntimeInstanceStatusValue
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
const { t } = useTranslation('deployments')
const label = t(deploymentStatusLabelKey(status))
return (
@@ -151,12 +149,12 @@ function RuntimeStatusSignal({ status, t }: {
)
}
function StatusSignal({ className, config, drift, t }: {
function StatusSignal({ className, config, drift }: {
className?: string
config: TileConfig
drift: ReturnType<typeof computeDrift>
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
const { t } = useTranslation('deployments')
const title = renderDriftTitle(config.kind, drift, t)
return (
@@ -1,5 +1,6 @@
'use client'
import type { ReactNode } from 'react'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
@@ -11,7 +12,7 @@ import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySectio
import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './release-hero'
function OverviewLayout({ children }: { children: React.ReactNode }) {
function OverviewLayout({ children }: { children: ReactNode }) {
return (
<div className="flex w-full min-w-0 flex-col gap-6 px-6 py-6">
{children}
@@ -20,7 +21,7 @@ function OverviewLayout({ children }: { children: React.ReactNode }) {
}
function LatestReleaseSection({ children }: {
children: React.ReactNode
children: ReactNode
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
@@ -18,18 +18,10 @@ import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { formatDate, releaseCommit } from '../../shared/domain/release'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
type ReleaseHeroProps = {
export function ReleaseHero({ latestRelease, releaseCount }: {
latestRelease?: Release
releaseCount: number
}
type ReleaseMetaItemProps = {
label?: string
showSeparator?: boolean
children: ReactNode
}
export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const { formatTimeFromNow } = useFormatTimeFromNow()
@@ -99,7 +91,11 @@ export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
)
}
function ReleaseMetaItem({ label, showSeparator = true, children }: ReleaseMetaItemProps) {
function ReleaseMetaItem({ label, showSeparator = true, children }: {
label?: string
showSeparator?: boolean
children: ReactNode
}) {
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{showSeparator && (
@@ -103,7 +103,7 @@ describe('DeployReleaseMenu', () => {
render(
<DeployReleaseMenu
releaseId={release.id}
release={release}
releaseRows={[release]}
/>,
)
@@ -52,6 +52,13 @@ function createReleaseRow(overrides: Partial<ReleaseWithSummaryDeployments> = {}
} as ReleaseWithSummaryDeployments
}
function requireElement<T extends Element>(element: T | null, label: string): T {
expect(element).not.toBeNull()
if (!element)
throw new Error(`${label} should exist`)
return element
}
describe('ReleaseHistoryRows', () => {
it('should render the desktop release list with the knowledge table style', () => {
const { container } = render(
@@ -60,11 +67,11 @@ describe('ReleaseHistoryRows', () => {
/>,
)
const table = container.querySelector('table')
const tableScope = within(table!)
const header = table!.querySelector('thead')
const headerCell = table!.querySelector('th')
const bodyRow = table!.querySelector('tbody tr')
const table = requireElement(container.querySelector('table'), 'release table')
const tableScope = within(table)
const header = requireElement(table.querySelector('thead'), 'release table header')
const headerCell = requireElement(table.querySelector('th'), 'release table header cell')
const bodyRow = requireElement(table.querySelector('tbody tr'), 'release table row')
expect(table).toHaveClass('w-full', 'border-collapse', 'border-0', 'text-sm')
expect(header).toHaveClass('border-b', 'border-divider-subtle')
@@ -88,8 +95,8 @@ describe('ReleaseHistoryRows', () => {
/>,
)
const table = container.querySelector('table')
const deploymentLabel = table!.querySelector('.text-util-colors-green-green-600')
const table = requireElement(container.querySelector('table'), 'release table')
const deploymentLabel = requireElement(table.querySelector('.text-util-colors-green-green-600'), 'deployment label')
expect(deploymentLabel).toHaveTextContent('test-cpu')
expect(deploymentLabel).toHaveClass('text-util-colors-green-green-600', 'system-xs-medium')
@@ -109,8 +116,8 @@ describe('ReleaseHistoryRows', () => {
/>,
)
const table = container.querySelector('table')
const sourceLink = within(table!).getByRole('link', { name: /Source Workflow/ })
const table = requireElement(container.querySelector('table'), 'release table')
const sourceLink = within(table).getByRole('link', { name: /Source Workflow/ })
expect(sourceLink).toHaveAttribute('href', '/app/source-app-1/workflow')
expect(sourceLink).toHaveAttribute('target', '_blank')
@@ -39,17 +39,19 @@ type ExportReleaseDslInput = {
appInstanceName?: string
}
export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
releaseId: string
export function DeployReleaseMenu({ release, releaseRows, onDeleted }: {
release: Release
releaseRows: Release[]
onDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
const releaseId = release.id
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const openReleaseMenuId = useAtomValue(deployReleaseMenuOpenReleaseIdAtom)
const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom)
const [showEditDialog, setShowEditDialog] = useState(false)
const [editDialogSessionKey, setEditDialogSessionKey] = useState(0)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const open = openReleaseMenuId === releaseId
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
@@ -63,13 +65,8 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? [])
.map(row => row.environment)
const deploymentRows = environmentDeploymentsQuery.data?.environmentDeployments.filter(row => !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find(release => release.id === releaseId)
const appInstanceName = appInstanceQuery.data?.appInstance.displayName
if (!targetRelease)
return null
const release = targetRelease
const targetReleaseName = release.displayName
const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows)
const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading
@@ -148,96 +145,96 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
handleOpenChange(false)
setEditDialogSessionKey(sessionKey => sessionKey + 1)
setShowEditDialog(true)
}}
>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{t('versions.editRelease')}
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={isExportingDsl}
aria-disabled={isExportingDsl}
className={cn(
'gap-2 px-3',
isExportingDsl && 'cursor-not-allowed opacity-60',
)}
onClick={handleExportDsl}
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
</span>
</DropdownMenuItem>
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
{groupedRows.map((section, sectionIndex) => (
<div key={section.group}>
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
{t(`versions.groupHeader.${section.group}`)}
</div>
{section.rows.map((row) => {
const isDisabled = row.state === 'current' || row.state === 'deploying'
return (
<TitleTooltip key={row.environmentId} content={isDisabled ? row.disabledReason : undefined}>
<DropdownMenuItem
disabled={isDisabled}
aria-disabled={isDisabled}
className={cn(
'gap-2 px-3',
isDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (isDisabled || !appInstanceId)
return
handleOpenChange(false)
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{row.label}
</span>
</DropdownMenuItem>
</TitleTooltip>
)
})}
</div>
))}
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<TitleTooltip content={deleteDisabledReason}>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
handleOpenChange(false)
setShowEditDialog(true)
}}
>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{t('versions.editRelease')}
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={isExportingDsl}
aria-disabled={isExportingDsl}
variant="destructive"
disabled={deleteActionDisabled}
aria-disabled={deleteActionDisabled}
className={cn(
'gap-2 px-3',
isExportingDsl && 'cursor-not-allowed opacity-60',
deleteActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={handleExportDsl}
onClick={() => {
if (deleteActionDisabled)
return
handleOpenChange(false)
setShowDeleteConfirm(true)
}}
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
</span>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('versions.deleteRelease')}</span>
</DropdownMenuItem>
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
{groupedRows.map((section, sectionIndex) => (
<div key={section.group}>
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
{t(`versions.groupHeader.${section.group}`)}
</div>
{section.rows.map((row) => {
const isDisabled = row.state === 'current' || row.state === 'deploying'
return (
<TitleTooltip key={row.environmentId} content={isDisabled ? row.disabledReason : undefined}>
<DropdownMenuItem
disabled={isDisabled}
aria-disabled={isDisabled}
className={cn(
'gap-2 px-3',
isDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (isDisabled || !appInstanceId)
return
handleOpenChange(false)
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{row.label}
</span>
</DropdownMenuItem>
</TitleTooltip>
)
})}
</div>
))}
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<TitleTooltip content={deleteDisabledReason}>
<DropdownMenuItem
variant="destructive"
disabled={deleteActionDisabled}
aria-disabled={deleteActionDisabled}
className={cn(
'gap-2 px-3',
deleteActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (deleteActionDisabled)
return
handleOpenChange(false)
setShowDeleteConfirm(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('versions.deleteRelease')}</span>
</DropdownMenuItem>
</TitleTooltip>
</DropdownMenuContent>
)}
</TitleTooltip>
</DropdownMenuContent>
</DropdownMenu>
<EditReleaseDialog
release={release}
open={showEditDialog}
resetKey={editDialogSessionKey}
onOpenChange={setShowEditDialog}
/>
@@ -0,0 +1,246 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
type EditReleaseFormValues = {
name: string
description: string
}
function normalizedEditReleaseFormValues(values: EditReleaseFormValues) {
return {
name: values.name.trim(),
description: values.description.trim(),
}
}
function canSubmitEditReleaseForm(initialValues: EditReleaseFormValues, values: EditReleaseFormValues) {
const normalizedValues = normalizedEditReleaseFormValues(values)
return Boolean(
normalizedValues.name
&& (
normalizedValues.name !== initialValues.name
|| normalizedValues.description !== initialValues.description
),
)
}
function EditReleaseForm({
initialValues,
isSaving,
onClose,
onSubmit,
}: {
initialValues: EditReleaseFormValues
isSaving: boolean
onClose: () => void
onSubmit: (values: EditReleaseFormValues) => void
}) {
const { t } = useTranslation('deployments')
const nameLabel = t('versions.releaseNameLabel')
const nameRequiredMessage = t('versions.releaseNameRequired')
function handleSubmit(values: EditReleaseFormValues) {
if (!canSubmitEditReleaseForm(initialValues, values))
return
onSubmit(normalizedEditReleaseFormValues(values))
}
return (
<Form<EditReleaseFormValues> className="flex flex-col gap-4" onFormSubmit={handleSubmit}>
<FieldRoot
name="name"
className="gap-2"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return nameRequiredMessage
return null
}}
>
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{nameLabel}
</FieldLabel>
<FieldControl
type="text"
defaultValue={initialValues.name}
maxLength={128}
autoComplete="off"
required
className="h-8"
/>
<FieldError match="valueMissing" className="system-xs-regular">{nameRequiredMessage}</FieldError>
<FieldError match="customError" className="system-xs-regular" />
</FieldRoot>
<FieldRoot name="description" className="gap-2">
<div className="flex items-center gap-1.5">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{t('versions.releaseDescriptionLabel')}
</FieldLabel>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<Textarea
defaultValue={initialValues.description}
maxLength={512}
autoComplete="off"
className="min-h-24"
/>
</FieldRoot>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={isSaving}
onClick={onClose}
>
{t('versions.cancelEdit')}
</Button>
<Button
type="submit"
variant="primary"
disabled={isSaving}
loading={isSaving}
>
{t('versions.saveEdit')}
</Button>
</div>
</Form>
)
}
function EditReleaseDialogContent({
release,
resetKey,
onClose,
onCloseBlockedChange,
}: {
release: Release
resetKey: number
onClose: () => void
onCloseBlockedChange: (blocked: boolean) => void
}) {
const { t } = useTranslation('deployments')
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
const formKey = `${resetKey}:${release.id}:${release.displayName}:${release.description}`
const isSaving = updateRelease.isPending
function handleClose() {
if (isSaving)
return
onClose()
}
function handleSubmit(values: EditReleaseFormValues) {
onCloseBlockedChange(true)
updateRelease.mutate(
{
params: {
releaseId: release.id,
},
body: {
releaseId: release.id,
displayName: values.name,
description: values.description,
},
},
{
onSuccess: (data) => {
const updatedName = data.release.displayName
toast.success(t('versions.editSuccess', { name: updatedName }))
onClose()
},
onError: () => {
toast.error(t('versions.editFailed'))
},
onSettled: () => {
onCloseBlockedChange(false)
},
},
)
}
return (
<>
<DialogCloseButton disabled={isSaving} />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.editRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.editReleaseDescription')}
</DialogDescription>
</div>
<div className="px-6 py-5">
<EditReleaseForm
key={formKey}
initialValues={{
name: release.displayName,
description: release.description,
}}
isSaving={isSaving}
onClose={handleClose}
onSubmit={handleSubmit}
/>
</div>
</>
)
}
export function EditReleaseDialog({
release,
open,
resetKey,
onOpenChange,
}: {
release: Release
open: boolean
resetKey: number
onOpenChange: (open: boolean) => void
}) {
const closeBlockedRef = useRef(false)
const [closeBlocked, setCloseBlocked] = useState(false)
function handleCloseBlockedChange(blocked: boolean) {
closeBlockedRef.current = blocked
setCloseBlocked(blocked)
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && closeBlockedRef.current)
return
onOpenChange(nextOpen)
}
return (
<Dialog open={open} disablePointerDismissal={closeBlocked} onOpenChange={handleOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<EditReleaseDialogContent
release={release}
resetKey={resetKey}
onClose={() => onOpenChange(false)}
onCloseBlockedChange={handleCloseBlockedChange}
/>
</DialogContent>
</Dialog>
)
}
@@ -1,7 +1,7 @@
'use client'
import { ReleaseHistoryTable } from './release-history-table'
export function VersionsTab() {
export function ReleasesTab() {
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">
<ReleaseHistoryTable />
@@ -24,7 +24,11 @@ function releaseDslFileName({ release, appInstanceName }: {
}) {
const projectName = sanitizeFileNamePart(appInstanceName)
const releaseName = sanitizeFileNamePart(release.displayName) || 'release'
const baseName = [projectName, releaseName].filter(Boolean).join('-')
const fileNameParts: string[] = []
if (projectName)
fileNameParts.push(projectName)
fileNameParts.push(releaseName)
const baseName = fileNameParts.join('-')
return `${baseName}.yaml`
}
@@ -116,63 +116,130 @@ function ReleaseSourceLink({ sourceAppId }: {
)
}
function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: {
function ReleaseHistoryMobileRow({ release, releaseRows, onReleaseDeleted }: {
release: ReleaseWithSummaryDeployments
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
const hasDeployments = release.summaryDeployments.length > 0
return (
<DetailTableCardList className="pc:hidden">
{releaseRows.map((row) => {
const release = row
const releaseId = release.id
const hasDeployments = row.summaryDeployments.length > 0
return (
<DetailTableCard key={releaseId}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ReleaseTitleTooltip release={release} />
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
<span aria-hidden>·</span>
<span>{row.createdBy.displayName}</span>
{(release.sourceAppId || release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && (
<>
<span aria-hidden>·</span>
<span className="inline-flex max-w-full min-w-0 items-baseline gap-1">
<span className="shrink-0">{t('versions.col.sourceApp')}</span>
<ReleaseSourceCell release={release} />
</span>
</>
)}
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu
releaseId={releaseId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</div>
{hasDeployments && (
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ReleaseDeploymentsContent
items={row.summaryDeployments}
/>
</div>
<DetailTableCard>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ReleaseTitleTooltip release={release} />
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
<span aria-hidden>·</span>
<span>{release.createdBy.displayName}</span>
{(release.sourceAppId || release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && (
<>
<span aria-hidden>·</span>
<span className="inline-flex max-w-full min-w-0 items-baseline gap-1">
<span className="shrink-0">{t('versions.col.sourceApp')}</span>
<ReleaseSourceCell release={release} />
</span>
</>
)}
</div>
</DetailTableCard>
)
})}
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu
release={release}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</div>
{hasDeployments && (
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ReleaseDeploymentsContent
items={release.summaryDeployments}
/>
</div>
)}
</div>
</DetailTableCard>
)
}
function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
return (
<DetailTableCardList className="pc:hidden">
{releaseRows.map(release => (
<ReleaseHistoryMobileRow
key={release.id}
release={release}
releaseRows={releaseRows}
onReleaseDeleted={onReleaseDeleted}
/>
))}
</DetailTableCardList>
)
}
function ReleaseHistoryDesktopRow({ release, releaseRows, onReleaseDeleted }: {
release: ReleaseWithSummaryDeployments
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
return (
<DetailTableRow>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>
<ReleaseTitleTooltip release={release} />
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>
<ReleaseSourceCell release={release} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`}>
<CreatedAtCell createdAt={release.createdAt} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`}>
{release.createdBy.displayName}
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={release.summaryDeployments}
/>
</div>
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action}>
<div className="flex justify-end">
<DeployReleaseMenu
release={release}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</DetailTableCell>
</DetailTableRow>
)
}
function ReleaseHistoryDesktopRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
return (
<>
{releaseRows.map(release => (
<ReleaseHistoryDesktopRow
key={release.id}
release={release}
releaseRows={releaseRows}
onReleaseDeleted={onReleaseDeleted}
/>
))}
</>
)
}
export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
@@ -198,43 +265,10 @@ export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: {
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{releaseRows.map((row) => {
const release = row
const releaseId = release.id
return (
<DetailTableRow key={releaseId}>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>
<ReleaseTitleTooltip release={release} />
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>
<ReleaseSourceCell release={release} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`}>
<CreatedAtCell createdAt={release.createdAt} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`}>
{row.createdBy.displayName}
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={row.summaryDeployments}
/>
</div>
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action}>
<div className="flex justify-end">
<DeployReleaseMenu
releaseId={releaseId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</DetailTableCell>
</DetailTableRow>
)
})}
<ReleaseHistoryDesktopRows
releaseRows={releaseRows}
onReleaseDeleted={onReleaseDeleted}
/>
</DetailTableBody>
</DetailTable>
</div>
@@ -83,7 +83,7 @@ export const setDeployReleaseMenuOpenAtom = atom(null, (get, set, {
set(deployReleaseMenuOpenReleaseIdAtom, undefined)
})
export const versionsTabLocalAtoms = [
export const releasesTabLocalAtoms = [
releaseHistoryCurrentPageAtom,
deployReleaseMenuOpenReleaseIdAtom,
] as const
@@ -1,188 +0,0 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
type EditReleaseFormValues = {
name: string
description: string
}
function EditReleaseForm({
release,
isSaving,
onClose,
onSubmit,
}: {
release: Release
isSaving: boolean
onClose: () => void
onSubmit: (values: EditReleaseFormValues) => void
}) {
const { t } = useTranslation('deployments')
const initialName = release.displayName
const initialDescription = release.description
const [name, setName] = useState(initialName)
const [description, setDescription] = useState(initialDescription)
const normalizedName = name.trim()
const normalizedDescription = description.trim()
const nameRequired = !normalizedName
const hasChanges = normalizedName !== initialName || normalizedDescription !== initialDescription
const canSave = Boolean(!nameRequired && hasChanges && !isSaving)
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSave)
return
onSubmit({
name: normalizedName,
description: normalizedDescription,
})
}
return (
<form className="flex flex-col gap-4" noValidate autoComplete="off" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-edit-name">
{t('versions.releaseNameLabel')}
</label>
<Input
id="release-edit-name"
type="text"
value={name}
maxLength={128}
autoComplete="off"
aria-invalid={nameRequired || undefined}
aria-describedby={nameRequired ? 'release-edit-name-error' : undefined}
onChange={event => setName(event.target.value)}
className="h-8"
/>
{nameRequired && (
<div id="release-edit-name-error" role="alert" className="system-xs-regular text-text-destructive">
{t('versions.releaseNameRequired')}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-edit-description">
{t('versions.releaseDescriptionLabel')}
</label>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<Textarea
id="release-edit-description"
value={description}
maxLength={512}
autoComplete="off"
onValueChange={setDescription}
className="min-h-24"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={isSaving}
onClick={onClose}
>
{t('versions.cancelEdit')}
</Button>
<Button
type="submit"
variant="primary"
disabled={!canSave}
loading={isSaving}
>
{t('versions.saveEdit')}
</Button>
</div>
</form>
)
}
export function EditReleaseDialog({
release,
open,
onOpenChange,
}: {
release: Release
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('deployments')
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
const formKey = `${release.id}-${release.displayName}-${release.description}`
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && updateRelease.isPending)
return
onOpenChange(nextOpen)
}
function handleSubmit(values: EditReleaseFormValues) {
updateRelease.mutate(
{
params: {
releaseId: release.id,
},
body: {
releaseId: release.id,
displayName: values.name,
description: values.description,
},
},
{
onSuccess: (data) => {
const updatedName = data.release.displayName
toast.success(t('versions.editSuccess', { name: updatedName }))
handleOpenChange(false)
},
onError: () => {
toast.error(t('versions.editFailed'))
},
},
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<DialogCloseButton disabled={updateRelease.isPending} />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.editRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.editReleaseDescription')}
</DialogDescription>
</div>
<div className="px-6 py-5">
<EditReleaseForm
key={formKey}
release={release}
isSaving={updateRelease.isPending}
onClose={() => handleOpenChange(false)}
onSubmit={handleSubmit}
/>
</div>
</DialogContent>
</Dialog>
)
}