This commit is contained in:
Stephen Zhou 2026-05-12 09:15:07 +08:00
parent 6d0d0763b1
commit 75bfb58cd9
No known key found for this signature in database
39 changed files with 1933 additions and 1035 deletions

View File

@ -20,7 +20,8 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own loading, empty, and error states for data rendered inside it.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
@ -56,7 +57,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
- Avoid shallow wrappers, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
## You Might Not Need An Effect

View File

@ -1,10 +1,10 @@
'use client'
import { useTranslation } from 'react-i18next'
import { DeploymentsMain } from '@/features/deployments/list'
import { DeploymentsList } from '@/features/deployments/list'
import useDocumentTitle from '@/hooks/use-document-title'
export default function DeploymentsPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.list'))
return <DeploymentsMain />
return <DeploymentsList />
}

View File

@ -5,7 +5,7 @@ import InSiteMessageNotification from '@/app/components/app/in-site-message/noti
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import { Header } from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'

View File

@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Header from '../index'
import { Header } from '../index'
function createMockComponent(testId: string) {
return () => <div data-testid={testId} />

View File

@ -1,6 +1,5 @@
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@ -29,7 +28,7 @@ const navClassName = `
cursor-pointer
`
const Header = () => {
export function Header() {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
@ -38,29 +37,32 @@ const Header = () => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isFreePlan = plan.type === Plan.sandbox
const isBrandingEnabled = systemFeatures.branding.enabled
const handlePlanClick = useCallback(() => {
function handlePlanClick() {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
}
const renderLogo = () => (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
function renderLogo() {
return (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
}
if (isMobile) {
return (
@ -74,14 +76,12 @@ const Header = () => {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center">
<div className="mr-2">
<PluginsNav />
</div>
<div className="flex items-center gap-2">
<PluginsNav />
<AccountDropdown />
</div>
</div>
<div className="my-1 flex items-center justify-center space-x-1">
<div className="my-1 flex items-center justify-center gap-1">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
@ -102,21 +102,18 @@ const Header = () => {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{isCurrentWorkspaceEditor && <DeploymentsNav />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end pr-3 pl-2 min-[1280px]:pl-3">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 pr-3 pl-2 min-[1280px]:pl-3">
<EnvNav />
<div className="mr-2">
<PluginsNav />
</div>
<PluginsNav />
<AccountDropdown />
</div>
</div>
)
}
export default Header

View File

@ -1,24 +1,134 @@
'use client'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppPicker } from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
import { AppTrigger } from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { closeCreateInstanceModalAtom, createInstanceModalOpenAtom } from '../store'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppTrigger({ open, app }: {
open: boolean
app?: App
}) {
const { t } = useTranslation('deployments')
return (
<span
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
app && 'py-1.5 pl-1.5',
)}
>
{app && (
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
)}
<span
title={app?.name}
className={cn(
'min-w-0 grow truncate',
app
? 'system-sm-medium text-components-input-text-filled'
: 'system-sm-regular text-components-input-text-placeholder',
)}
>
{app?.name ?? t('createModal.appPickerPlaceholder')}
</span>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
aria-hidden="true"
/>
</span>
)
}
function SourceAppOption({ app }: {
app: App
}) {
const { t } = useTranslation('deployments')
const modeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
return (
<ComboboxItem
value={app}
className="mx-0 grid-cols-[minmax(0,1fr)_auto] gap-3 py-1 pr-3 pl-2"
>
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span title={`${app.name} (${app.id})`} className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
<span className="truncate">{app.name}</span>
<span className="shrink-0 text-text-tertiary">
(
{app.id.slice(0, 8)}
)
</span>
</span>
</ComboboxItemText>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{modeLabel}</span>
</ComboboxItem>
)
}
function SourceAppPickerSkeleton() {
return (
<div className="flex flex-col gap-2 px-3 py-3">
{SOURCE_APP_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-7 gap-3">
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</SkeletonRow>
))}
</div>
)
}
function SourceAppPicker({ value, onChange }: {
value?: App
onChange: (app: App) => void
}) {
const { t } = useTranslation('deployments')
const [isShow, setIsShow] = useState(false)
const [searchText, setSearchText] = useState('')
@ -46,30 +156,84 @@ function SourceAppPicker({ value, onChange }: {
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<AppPicker
disabled={false}
trigger={<AppTrigger open={isShow} appDetail={value} />}
isShow={isShow}
onShowChange={setIsShow}
onSelect={onChange}
apps={apps}
isLoading={isLoading || isFetchingNextPage}
hasMore={hasNextPage ?? true}
onLoadMore={() => {
void fetchNextPage()
<Combobox<App>
items={apps}
open={isShow}
inputValue={searchText}
onOpenChange={setIsShow}
onInputValueChange={setSearchText}
onValueChange={(app) => {
if (!app)
return
onChange(app)
setIsShow(false)
}}
searchText={searchText}
onSearchChange={setSearchText}
placement="bottom-start"
offset={4}
/>
itemToStringLabel={app => app?.name ?? ''}
itemToStringValue={app => app?.id ?? ''}
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
disabled={false}
>
<ComboboxTrigger
aria-label={t('createModal.sourceApp')}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
<SourceAppTrigger open={isShow} app={value} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('createModal.appSearchPlaceholder')}
placeholder={t('createModal.appSearchPlaceholder')}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<SourceAppOption key={app.id} app={app} />
)}
</ComboboxList>
{!(isLoading || isFetchingNextPage) && (
<ComboboxEmpty>
{t('createModal.appSearchEmpty')}
</ComboboxEmpty>
)}
{hasNextPage && (
<div className="flex justify-center px-3 py-2">
<Button
type="button"
size="small"
disabled={isFetchingNextPage}
onClick={() => {
void fetchNextPage()
}}
>
{isFetchingNextPage ? t('common.loading') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
</div>
</ComboboxContent>
</Combobox>
)
}
function CreateInstanceForm() {
function CreateInstanceForm({ onClose }: {
onClose: () => void
}) {
const { t } = useTranslation('deployments')
const router = useRouter()
const closeModal = useSetAtom(closeCreateInstanceModalAtom)
const createInstance = useMutation(consoleQuery.enterprise.appDeploy.createAppInstance.mutationOptions())
const [sourceApp, setSourceApp] = useState<App>()
@ -96,7 +260,7 @@ function CreateInstanceForm() {
})
if (!result.appInstanceId)
throw new Error('Create app instance did not return an appInstanceId.')
closeModal()
onClose()
router.push(`/deployments/${result.appInstanceId}/overview`)
}
catch {
@ -133,13 +297,13 @@ function CreateInstanceForm() {
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-name">
{t('createModal.nameLabel')}
</label>
<input
<Input
id="instance-name"
name="name"
type="text"
placeholder={sourceApp?.name ?? t('createModal.namePlaceholder')}
required
className="flex h-8 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 system-sm-medium text-text-secondary outline-hidden placeholder:text-text-quaternary"
className="h-8"
/>
</div>
@ -151,12 +315,12 @@ function CreateInstanceForm() {
id="instance-desc"
name="description"
placeholder={t('createModal.descriptionPlaceholder')}
className="min-h-20 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2 system-sm-regular text-text-secondary outline-hidden placeholder:text-text-quaternary"
className="min-h-20 w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={closeModal}>
<Button type="button" variant="secondary" onClick={onClose}>
{t('createModal.cancel')}
</Button>
<Button type="submit" variant="primary" disabled={!canCreate}>
@ -167,18 +331,18 @@ function CreateInstanceForm() {
)
}
export function CreateInstanceModal() {
const open = useAtomValue(createInstanceModalOpenAtom)
const closeModal = useSetAtom(closeCreateInstanceModalAtom)
export function CreateInstanceModal({ open, onOpenChange }: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog
open={open}
onOpenChange={next => !next && closeModal()}
onOpenChange={onOpenChange}
>
<DialogContent className="w-130 max-w-[90vw]">
<DialogCloseButton />
{open && <CreateInstanceForm />}
{open && <CreateInstanceForm onClose={() => onOpenChange(false)} />}
</DialogContent>
</Dialog>
)

View File

@ -1,11 +1,8 @@
'use client'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DEPLOYMENT_PAGE_SIZE } from '../data'
import {
closeDeployDrawerAtom,
deployDrawerAppInstanceIdAtom,
@ -22,30 +19,6 @@ export function DeployDrawer() {
const drawerEnvironmentId = useAtomValue(deployDrawerEnvironmentIdAtom)
const drawerReleaseId = useAtomValue(deployDrawerReleaseIdAtom)
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const { data: releaseHistory } = useQuery(consoleQuery.enterprise.appDeploy.listReleases.queryOptions({
input: drawerAppInstanceId
? {
params: { appInstanceId: drawerAppInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
}
: skipToken,
enabled: open && Boolean(drawerAppInstanceId),
}))
const { data: environmentOptionsReply } = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions({
enabled: open,
}))
const environments = environmentOptionsReply?.environments
?.filter(environment => environment.id)
.map(environment => ({
...environment,
disabled: environment.deployable === false,
})) ?? []
const releases = releaseHistory?.data?.filter(release => release.id) ?? []
const defaultReleaseId = releases[0]?.id
const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}`
return (
@ -57,24 +30,14 @@ export function DeployDrawer() {
<DialogCloseButton />
{!drawerAppInstanceId
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (!releaseHistory || !environmentOptionsReply)
? (
<div className="flex items-center gap-2 p-4 system-sm-regular text-text-tertiary">
<span className="size-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
{t('createModal.loadingApps')}
</div>
)
: (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
environments={environments}
releases={releases}
defaultReleaseId={defaultReleaseId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
: (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
</DialogContent>
</Dialog>
)

View File

@ -1,6 +1,6 @@
'use client'
import type { DeploymentBindingOptionSlot, DeploymentEnvironmentOption, DeploymentRuntimeBinding, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import type { DeploymentBindingOptionSlot, DeploymentEnvironmentOption, DeploymentRuntimeBinding, ReleaseRow, RuntimeInstanceRow } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
@ -8,7 +8,9 @@ import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
import { environmentId, environmentMode, environmentName } from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { releaseDeploymentAction } from '../../release-action'
@ -22,17 +24,23 @@ import {
type DeployFormProps = {
appInstanceId: string
lockedEnvId?: string
presetReleaseId?: string
}
type DeployReadyFormProps = DeployFormProps & {
environments: EnvironmentOption[]
releases: ReleaseRow[]
defaultReleaseId?: string
lockedEnvId?: string
presetReleaseId?: string
runtimeRows: RuntimeInstanceRow[]
}
type EnvironmentOption = DeploymentEnvironmentOption & {
disabled?: boolean
}
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
type BindingSelections = Record<string, string>
type BindingSelectOption = {
@ -125,8 +133,12 @@ function BindingOptionsPanel({
if (isLoading) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-quaternary">
{t('deployDrawer.loadingBindings')}
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
)
}
@ -201,22 +213,49 @@ function BindingOptionsPanel({
)
}
export function DeployForm({
function DeployFormSkeleton() {
return (
<div className="flex flex-col gap-5">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
</SkeletonContainer>
{DEPLOY_FORM_FIELD_SKELETON_KEYS.map(key => (
<SkeletonContainer key={key} className="gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
))}
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
<SkeletonRow className="justify-end">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-22 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function DeployReadyForm({
appInstanceId,
environments,
releases,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
}: DeployFormProps) {
runtimeRows,
}: DeployReadyFormProps) {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const startDeploy = useMutation(consoleQuery.enterprise.appDeploy.createDeployment.mutationOptions())
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input: {
params: { appInstanceId },
},
}))
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
const displayedRelease: ReleaseRow | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
const isExistingRelease = Boolean(presetReleaseId)
@ -232,7 +271,7 @@ export function DeployForm({
const selectedRelease = releases.find(release => release.id === selectedReleaseId)
const targetReleaseId = displayedRelease?.id ?? selectedRelease?.id ?? selectedReleaseId
const targetRelease = displayedRelease ?? selectedRelease ?? (targetReleaseId ? { id: targetReleaseId } : undefined)
const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
const deploymentRows = runtimeRows.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row))
const selectedDeploymentRow = deploymentRows.find(row => environmentId(row.environment) === selectedEnvironmentId)
const action = releaseDeploymentAction({
targetRelease,
@ -297,32 +336,25 @@ export function DeployForm({
if (!canDeploy || !targetReleaseId)
return
void (async () => {
try {
await startDeploy.mutateAsync({
params: {
appInstanceId,
},
body: {
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
bindings: deploymentBindings,
},
})
closeDeployDrawer()
}
catch {
toast.error(t('deployDrawer.deployFailed'))
}
})()
}
if (!environmentDeployments) {
return (
<div className="flex items-center gap-2 p-4 system-sm-regular text-text-tertiary">
<span className="size-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
{t('createModal.loadingApps')}
</div>
startDeploy.mutate(
{
params: {
appInstanceId,
},
body: {
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
bindings: deploymentBindings,
},
},
{
onSuccess: () => {
closeDeployDrawer()
},
onError: () => {
toast.error(t('deployDrawer.deployFailed'))
},
},
)
}
@ -405,7 +437,7 @@ export function DeployForm({
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={closeDeployDrawer}>
<Button type="button" variant="secondary" onClick={closeDeployDrawer}>
{t('deployDrawer.cancel')}
</Button>
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
@ -415,3 +447,62 @@ export function DeployForm({
</div>
)
}
export function DeployForm({
appInstanceId,
lockedEnvId,
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.appDeploy.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const environmentOptionsQuery = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions())
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input: {
params: { appInstanceId },
},
}))
if (releaseHistoryQuery.isLoading || environmentOptionsQuery.isLoading || runtimeInstancesQuery.isLoading) {
return <DeployFormSkeleton />
}
if (releaseHistoryQuery.isError || environmentOptionsQuery.isError || runtimeInstancesQuery.isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t('common.loadFailed')}
</div>
)
}
const environments = environmentOptionsQuery.data?.environments
?.filter(environment => environment.id)
.map(environment => ({
...environment,
disabled: environment.deployable === false,
})) ?? []
const releases = releaseHistoryQuery.data?.data?.filter(release => release.id) ?? []
const defaultReleaseId = releases[0]?.id
const runtimeRows = runtimeInstancesQuery.data?.data ?? []
const formKey = `${appInstanceId}-${lockedEnvId ?? 'any'}-${presetReleaseId ?? 'new'}-${defaultReleaseId ?? 'none'}`
return (
<DeployReadyForm
key={formKey}
appInstanceId={appInstanceId}
environments={environments}
releases={releases}
defaultReleaseId={defaultReleaseId}
lockedEnvId={lockedEnvId}
presetReleaseId={presetReleaseId}
runtimeRows={runtimeRows}
/>
)
}

View File

@ -2,7 +2,7 @@
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type DeployStatus = 'ready' | 'deploying' | 'deploy_failed'
type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' | 'unknown'
type EnvironmentMode = 'shared' | 'isolated'
type EnvironmentHealth = 'ready' | 'degraded'
@ -10,12 +10,14 @@ const statusStyles: Record<DeployStatus, string> = {
ready: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
deploy_failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
unknown: 'border-divider-subtle bg-background-default-subtle text-text-tertiary',
}
const statusKey = {
ready: 'status.ready',
deploying: 'status.deploying',
deploy_failed: 'status.deployFailed',
unknown: 'status.unknown',
} as const satisfies Record<DeployStatus, string>
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'

View File

@ -1,2 +1,12 @@
import type { Pagination } from '@dify/contracts/enterprise/types.gen'
export const DEPLOYMENT_PAGE_SIZE = 100
export const RELEASE_HISTORY_PAGE_SIZE = 20
export const SOURCE_APPS_PAGE_SIZE = 100
export function getNextPageParamFromPagination(pagination?: Pagination) {
const currentPage = pagination?.currentPage ?? 1
const totalPages = pagination?.totalPages ?? 1
return currentPage < totalPages ? currentPage + 1 : undefined
}

View File

@ -1,157 +0,0 @@
'use client'
import { Switch } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../environment'
import { webappUrl } from '../../webapp-url'
import { Section } from '../common'
import { CopyPill, EndpointRow } from './common'
import { getUrlOrigin } from './url'
type AccessChannelsSectionProps = {
appInstanceId: string
}
function AccessChannelsSwitch({ appInstanceId, checked }: {
appInstanceId: string
checked: boolean
}) {
const toggleAccessChannel = useMutation(consoleQuery.enterprise.appDeploy.updateAccessChannels.mutationOptions())
return (
<Switch
checked={checked}
onCheckedChange={(enabled) => {
toggleAccessChannel.mutate({
params: { appInstanceId },
body: { enabled },
})
}}
/>
)
}
export function AccessChannelsSection({
appInstanceId,
}: AccessChannelsSectionProps) {
const { t } = useTranslation('deployments')
const { data: accessConfig } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const runEnabled = accessConfig?.accessChannels?.enabled ?? false
const webappRows = accessConfig?.accessChannels?.webappRows?.filter(row => row.url) ?? []
const cliDomain = getUrlOrigin(accessConfig?.accessChannels?.cli?.url)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
action={(
<AccessChannelsSwitch
appInstanceId={appInstanceId}
checked={runEnabled}
/>
)}
>
{runEnabled
? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.runAccess.webapp')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.runAccess.webappDesc')}
</div>
{webappRows.length > 0
? (
<div className="flex flex-col gap-2">
{webappRows.map((row) => {
const endpointUrl = webappUrl(row.url)
return (
<EndpointRow
key={`webapp-${row.environment?.id ?? row.url}`}
envName={environmentName(row.environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{t('access.runAccess.webappEmpty')}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 border-t border-divider-subtle pt-3">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.cli.title')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.description')}
</div>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-65 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>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
</div>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.channels.disabled')}
</div>
)}
</Section>
)
}

View File

@ -1,167 +0,0 @@
'use client'
import type {
ConsoleEnvironment,
EnvironmentAccessRow,
} from '@dify/contracts/enterprise/types.gen'
import { Switch } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { Section } from '../common'
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
import { CopyPill } from './common'
type DeveloperApiSectionProps = {
appInstanceId: string
}
type CreatedApiToken = {
appInstanceId: string
token: string
}
function permissionEnvironment(row: EnvironmentAccessRow): ConsoleEnvironment | undefined {
return row.environment?.id ? row.environment : undefined
}
function DeveloperApiSwitch({ appInstanceId, checked }: {
appInstanceId: string
checked: boolean
}) {
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.appDeploy.updateDeveloperApi.mutationOptions())
return (
<Switch
checked={checked}
onCheckedChange={(enabled) => {
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: { enabled },
})
}}
/>
)
}
function CreatedApiTokenCard({ token, onDismiss }: {
token: string
onDismiss: () => void
}) {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.newTokenTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</span>
</div>
<button
type="button"
onClick={onDismiss}
aria-label={t('access.api.dismissToken')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-close-line size-3.5" />
</button>
</div>
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
</div>
)
}
export function DeveloperApiSection({
appInstanceId,
}: DeveloperApiSectionProps) {
const { t } = useTranslation('deployments')
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const { data: accessConfig } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
const apiUrl = accessConfig?.developerApi?.apiUrl
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
const environments = accessConfig?.permissions
?.map(permissionEnvironment)
.filter((environment): environment is ConsoleEnvironment => Boolean(environment)) ?? []
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
return (
<Section
title={t('access.api.developerTitle')}
description={t('access.api.description')}
action={(
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
/>
)}
>
{apiEnabled
? (
<div className="flex flex-col gap-2">
{apiUrl && (
<CopyPill
label={t('access.api.endpoint')}
value={apiUrl}
/>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.backendTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.keyList')}
</span>
</div>
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
apiKeys={apiKeys}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
</div>
{visibleCreatedApiToken && (
<CreatedApiTokenCard
token={visibleCreatedApiToken}
onDismiss={() => setCreatedApiToken(undefined)}
/>
)}
{apiKeys.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{environments.length === 0
? t('access.api.empty')
: t('access.api.noKeys')}
</div>
)
: (
<ApiKeyList
appInstanceId={appInstanceId}
apiKeys={apiKeys}
/>
)}
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.api.disabled')}
</div>
)}
</Section>
)
}

View File

@ -1,58 +0,0 @@
'use client'
import type {
EnvironmentAccessRow,
} from '@dify/contracts/enterprise/types.gen'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { Section } from '../common'
import { EnvironmentPermissionRow } from './permissions'
type AccessPermissionsSectionProps = {
appInstanceId: string
}
function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow & {
environment: NonNullable<EnvironmentAccessRow['environment']>
} {
return Boolean(row.environment?.id)
}
export function AccessPermissionsSection({
appInstanceId,
}: AccessPermissionsSectionProps) {
const { t } = useTranslation('deployments')
const { data: accessConfig } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const permissionRows = accessConfig?.permissions?.filter(hasEnvironment) ?? []
return (
<Section
title={t('access.permissions.title')}
description={t('access.permissions.description')}
>
{permissionRows.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{t('access.runAccess.noEnvs')}
</div>
)
: (
<div className="flex flex-col gap-3">
{permissionRows.map(row => (
<EnvironmentPermissionRow
key={row.environment.id}
appInstanceId={appInstanceId}
environment={row.environment}
summaryPolicy={row}
/>
))}
</div>
)}
</Section>
)
}

View File

@ -9,6 +9,16 @@ type SectionProps = {
children: ReactNode
}
export function SectionState({ children }: {
children: ReactNode
}) {
return (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
export function Section({ title, description, action, children }: SectionProps) {
return (
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">

View File

@ -11,10 +11,12 @@ import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentId, environmentName } from '../environment'
import { isUndeployedDeploymentRow } from '../runtime-status'
import { openDeployDrawerAtom } from '../store'
import { SectionState } from './common'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
type EnvironmentOption = DeploymentEnvironmentOption & {
@ -81,16 +83,63 @@ function NewDeploymentMenu({ appInstanceId, availableEnvs }: {
)
}
function NewDeploymentMenuSkeleton() {
return <SkeletonRectangle className="my-0 h-8 w-36 animate-pulse rounded-lg" />
}
const DEPLOYMENT_TABLE_HEADER_SKELETON_KEYS = ['environment', 'release', 'updated-at', 'actions']
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging', 'development', 'preview']
function DeploymentEnvironmentListSkeleton() {
return (
<div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg">
<div className="hidden items-center gap-4 border-b border-divider-subtle px-4 py-3 lg:grid lg:grid-cols-[minmax(180px,1fr)_minmax(140px,0.75fr)_minmax(180px,0.85fr)_240px]">
{DEPLOYMENT_TABLE_HEADER_SKELETON_KEYS.map(key => (
<SkeletonRectangle key={key} className="h-3 w-24 animate-pulse" />
))}
</div>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className="border-b border-divider-subtle last:border-b-0">
<div className="flex w-full flex-col gap-2 px-4 py-3 lg:grid lg:grid-cols-[minmax(180px,1fr)_minmax(140px,0.75fr)_minmax(180px,0.85fr)_240px] lg:items-center lg:gap-4">
<div className="flex min-w-0 items-start justify-between gap-3 lg:block">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-24 animate-pulse" />
</div>
<div className="flex shrink-0 items-center gap-1 lg:hidden">
<SkeletonRectangle className="my-0 h-7 w-28 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded" />
</div>
</div>
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-14 animate-pulse" />
</SkeletonRow>
<div className="min-w-0">
<SkeletonRectangle className="my-0 h-5 w-36 animate-pulse rounded-md" />
</div>
<div className="hidden justify-end lg:flex">
<SkeletonRectangle className="my-0 h-7 w-32 animate-pulse rounded-lg" />
</div>
</div>
</div>
))}
</div>
)
}
export function DeployTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input: {
params: { appInstanceId },
},
}))
const { data: environmentOptionsReply } = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions())
const environmentOptionsQuery = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions())
const environmentDeployments = environmentDeploymentsQuery.data
const environmentOptionsReply = environmentOptionsQuery.data
const environmentOptions = environmentOptionsReply?.environments
?.filter(environment => environment.id)
.map(environment => ({
@ -102,6 +151,8 @@ export function DeployTab({ appInstanceId }: {
const deployedEnvIds = new Set(deployedRuntimeRows.map(row => environmentId(row.environment)))
const availableEnvs = environmentOptions.filter(env => env.id && !deployedEnvIds.has(env.id))
const isLoading = environmentDeploymentsQuery.isLoading || environmentOptionsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError || environmentOptionsQuery.isError
return (
<div className="flex w-full max-w-240 flex-col gap-4 p-6">
@ -115,18 +166,24 @@ export function DeployTab({ appInstanceId }: {
)
</span>
</div>
<NewDeploymentMenu appInstanceId={appInstanceId} availableEnvs={availableEnvs} />
{isLoading
? <NewDeploymentMenuSkeleton />
: !hasError && <NewDeploymentMenu appInstanceId={appInstanceId} availableEnvs={availableEnvs} />}
</div>
{rows.length === 0
? (
<div className="rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{t('deployTab.empty')}
</div>
)
: (
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
)}
{isLoading
? <DeploymentEnvironmentListSkeleton />
: hasError
? <SectionState>{t('common.loadFailed')}</SectionState>
: rows.length === 0
? (
<div className="rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{t('deployTab.empty')}
</div>
)
: (
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
)}
</div>
)
}

View File

@ -41,9 +41,12 @@ function DeploymentRowActions({ appInstanceId, envId, row }: {
const undeployDeployment = useMutation(consoleQuery.enterprise.appDeploy.undeployRuntimeInstance.mutationOptions())
const isUndeployed = isUndeployedDeploymentRow(row)
const status = deploymentStatus(row)
const runtimeInstanceId = row.id
function handleRuntimeAction() {
const runtimeInstanceId = row.id ?? ''
if (!runtimeInstanceId)
return
setMenuOpen(false)
if (status === 'deploying') {
@ -85,12 +88,15 @@ function DeploymentRowActions({ appInstanceId, envId, row }: {
? t('deployTab.deployOtherVersion')
: status === 'deploying'
? t('deployTab.viewProgress')
: t('deployTab.viewError')}
: status === 'deploy_failed'
? t('deployTab.viewError')
: t('deployTab.deployOtherVersion')}
</Button>
{!isUndeployed && (
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
disabled={!runtimeInstanceId}
className="flex size-7 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-more-line size-4" />

View File

@ -18,8 +18,8 @@ function InfoBlock({ title, children }: {
children: ReactNode
}) {
return (
<div className="min-w-0 rounded-lg bg-background-default px-3 py-2.5">
<div className="mb-2 system-xs-medium-uppercase text-text-tertiary">{title}</div>
<div className="flex min-w-0 flex-col gap-2 rounded-lg bg-background-default px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
<div className="flex flex-col gap-1.5">{children}</div>
</div>
)

View File

@ -42,6 +42,15 @@ export function DeploymentStatusSummary({ row }: {
)
}
if (status === 'unknown') {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-text-tertiary">
<span className="i-ri-question-line size-3.5" />
{t('status.unknown')}
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
<span className="size-1.5 rounded-full bg-util-colors-green-green-500" />

View File

@ -1,10 +1,10 @@
'use client'
import type { AppInstanceBasicInfo } from '@dify/contracts/enterprise/types.gen'
import type { ComponentProps, PropsWithoutRef } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useHover, useKeyPress, useLocalStorageState } from 'ahooks'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
@ -13,8 +13,10 @@ import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
type TabDef = {
@ -91,23 +93,104 @@ function useDeploymentSidebarMode(isMobile: boolean) {
}
type DeploymentSidebarProps = {
app: AppInstanceBasicInfo
appInstanceId: string
}
export function DeploymentSidebar({
app,
}: DeploymentSidebarProps) {
function DeploymentSidebarInstanceInfo({ appInstanceId, expand }: {
appInstanceId: string
expand: boolean
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation()
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input: {
params: { appInstanceId },
},
}))
const app = overviewQuery.data?.instance
const isLoading = !app?.id && overviewQuery.isLoading
const isUnavailable = !app?.id || overviewQuery.isError
const instanceName = app?.name ?? appInstanceId
const appModeLabel = app?.id ? getAppModeLabel(toAppMode(app.mode), tCommon) : ''
return (
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
{isLoading
? (
<>
<SkeletonRectangle className={cn('my-0 animate-pulse rounded-lg', expand ? 'size-10' : 'size-8')} />
{expand && (
<SkeletonContainer className="w-full gap-1">
<SkeletonRectangle className="my-0 h-5 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" />
</SkeletonContainer>
)}
</>
)
: isUnavailable
? (
<>
<div className="flex size-8 items-center justify-center rounded-lg bg-components-icon-bg-orange-solid text-text-primary-on-surface">
<span className="i-ri-rocket-line size-4" />
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary">
{t('detail.notFound')}
</div>
<div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary" title={appInstanceId}>
{appInstanceId}
</div>
</div>
)}
</>
)
: (
<>
<div className="flex items-center gap-1">
<AppIcon
size={expand ? 'large' : 'medium'}
iconType="emoji"
icon={app.icon}
background={app.iconBackground}
/>
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
{instanceName}
</div>
</div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{appModeLabel}
</div>
{app.description && (
<div
className="line-clamp-2 system-xs-regular text-text-tertiary"
title={app.description}
>
{app.description}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
export function DeploymentSidebar({ appInstanceId }: DeploymentSidebarProps) {
const { t } = useTranslation('deployments')
const sidebarRef = useRef<HTMLDivElement>(null)
const isHoveringSidebar = useHover(sidebarRef)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { sidebarMode, toggleSidebarMode } = useDeploymentSidebarMode(isMobile)
const expand = sidebarMode === 'expand'
const appInstanceId = app.id ?? ''
const instanceName = app.name ?? appInstanceId
const appModeLabel = getAppModeLabel(toAppMode(app.mode), tCommon)
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
@ -125,38 +208,7 @@ export function DeploymentSidebar({
expand ? 'w-54' : 'w-14',
)}
>
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
<div className="flex items-center gap-1">
<AppIcon
size={expand ? 'large' : 'medium'}
iconType="emoji"
icon={app.icon}
background={app.iconBackground}
/>
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
{instanceName}
</div>
</div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{appModeLabel}
</div>
{app.description && (
<div
className="line-clamp-2 system-xs-regular text-text-tertiary"
title={app.description}
>
{app.description}
</div>
)}
</div>
)}
</div>
</div>
<DeploymentSidebarInstanceInfo appInstanceId={appInstanceId} expand={expand} />
<div className="relative px-4 py-2">
<Divider

View File

@ -2,13 +2,9 @@
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { DeployDrawer } from '../components/deploy-drawer'
import { DeploymentSidebar } from './deployment-sidebar'
import { isInstanceDetailTabKey } from './tabs'
@ -21,44 +17,13 @@ export function InstanceDetail({ appInstanceId, children }: {
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input: {
params: { appInstanceId },
},
}))
useDocumentTitle(t('documentTitle.detail'))
const app = overviewQuery.data?.instance
const resolvedAppInstanceId = app?.id
if (!resolvedAppInstanceId && overviewQuery.isLoading) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<span className="size-6 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
</div>
)
}
if (!resolvedAppInstanceId || !app) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-body">
<div className="title-xl-semi-bold text-text-primary">{t('detail.notFound')}</div>
<Button nativeButton={false} variant="secondary" render={<Link href="/deployments" />}>
<span aria-hidden className="i-ri-arrow-left-line size-4" />
{t('detail.backToInstances')}
</Button>
</div>
)
}
return (
<>
<div className="relative flex h-full overflow-hidden rounded-t-2xl shadow-xs">
<DeploymentSidebar
app={app}
/>
<DeploymentSidebar appInstanceId={appInstanceId} />
<div className="grow overflow-hidden bg-components-panel-bg">
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b border-solid border-b-divider-regular px-6 py-3">
@ -75,7 +40,6 @@ export function InstanceDetail({ appInstanceId, children }: {
</div>
</div>
</div>
<DeployDrawer />
</>
)

View File

@ -6,15 +6,80 @@ import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
import { StatusBadge } from '../components/status-badge'
import { DEPLOYMENT_PAGE_SIZE } from '../data'
import { releaseLabel } from '../release'
import { deploymentStatus } from '../runtime-status'
import { openDeployDrawerAtom } from '../store'
import { webappUrl } from '../webapp-url'
import { Section } from './common'
import { Section, SectionState } from './common'
const STATUS_ROW_SKELETON_KEYS = ['primary', 'secondary']
function InfoRowsSkeleton({ rows }: {
rows: Array<{
key: string
label: string
valueClassName: string
}>
}) {
return (
<div className="flex flex-col divide-y divide-divider-subtle">
{rows.map(row => (
<div key={row.key} className="flex items-start gap-3 py-1.5">
<span className="w-32 shrink-0 system-xs-regular text-text-tertiary">{row.label}</span>
<div className="min-w-0 flex-1 py-1">
<SkeletonRectangle className={cn('my-0 h-3 animate-pulse', row.valueClassName)} />
</div>
</div>
))}
</div>
)
}
function StatusRowsSkeleton() {
return (
<div className="flex flex-col divide-y divide-divider-subtle">
{STATUS_ROW_SKELETON_KEYS.map(key => (
<div key={key} className="flex items-center justify-between gap-3 py-2">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-24 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
</div>
))}
</div>
)
}
function AccessRowsSkeleton({ rows }: {
rows: Array<{
key: string
label: string
hintClassName: string
showMeta?: boolean
}>
}) {
return (
<div className="flex flex-col divide-y divide-divider-subtle">
{rows.map(row => (
<div key={row.key} className="flex items-center justify-between gap-3 py-1.5">
<div className="flex min-w-0 flex-col gap-1.5">
<span className="system-sm-medium text-text-primary">{row.label}</span>
<SkeletonRectangle className={cn('my-0 h-2.5 animate-pulse', row.hintClassName)} />
{row.showMeta && <SkeletonRectangle className="my-0 h-2.5 w-14 animate-pulse" />}
</div>
<SkeletonRectangle className="my-0 h-4 w-16 animate-pulse" />
</div>
))}
</div>
)
}
function InfoRow({ label, value, mono }: {
label: string
@ -29,14 +94,12 @@ function InfoRow({ label, value, mono }: {
)
}
type AccessOverviewRowProps = {
function AccessOverviewRow({ label, enabled, hint, meta }: {
label: string
enabled: boolean
hint?: string
meta?: string
}
function AccessOverviewRow({ label, enabled, hint, meta }: AccessOverviewRowProps) {
}) {
const { t } = useTranslation('deployments')
return (
@ -62,15 +125,6 @@ function AccessOverviewRow({ label, enabled, hint, meta }: AccessOverviewRowProp
)
}
function overviewDeploymentStatus(status?: string): 'deploying' | 'deploy_failed' | 'ready' {
const normalized = status?.toLowerCase() ?? ''
if (normalized.includes('deploying') || normalized.includes('pending'))
return 'deploying'
if (normalized.includes('fail') || normalized.includes('error'))
return 'deploy_failed'
return 'ready'
}
function DeployFromOverviewButton({ appInstanceId }: {
appInstanceId: string
}) {
@ -89,15 +143,43 @@ function BasicInfoSection({ appInstanceId }: {
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation()
const { data: overview } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input: {
params: { appInstanceId },
},
}))
const overview = overviewQuery.data
const overviewApp = overview?.instance
const skeletonRows = [
{ key: 'name', label: t('overview.name'), valueClassName: 'w-30' },
{ key: 'description', label: t('overview.description'), valueClassName: 'w-18' },
{ key: 'source-app', label: t('overview.sourceApp'), valueClassName: 'w-30' },
{ key: 'app-mode', label: t('overview.appMode'), valueClassName: 'w-18' },
]
if (!overviewApp?.id)
return null
if (overviewQuery.isLoading) {
return (
<Section title={t('overview.basicInfo')}>
<InfoRowsSkeleton rows={skeletonRows} />
</Section>
)
}
if (overviewQuery.isError) {
return (
<Section title={t('overview.basicInfo')}>
<SectionState>{t('common.loadFailed')}</SectionState>
</Section>
)
}
if (!overviewApp?.id) {
return (
<Section title={t('overview.basicInfo')}>
<SectionState>{t('detail.notFound')}</SectionState>
</Section>
)
}
const appName = overviewApp.name ?? overviewApp.id
const appModeLabel = getAppModeLabel(toAppMode(overviewApp.mode), tCommon)
@ -119,10 +201,10 @@ function DeploymentStatusSection({ appInstanceId }: {
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId } }
const { data: overview } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input,
}))
const { data: releaseHistory } = useQuery(consoleQuery.enterprise.appDeploy.listReleases.queryOptions({
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.appDeploy.listReleases.queryOptions({
input: {
...input,
query: {
@ -131,23 +213,47 @@ function DeploymentStatusSection({ appInstanceId }: {
},
},
}))
const overview = overviewQuery.data
const releaseHistory = releaseHistoryQuery.data
const overviewApp = overview?.instance
const deployments = overview?.deployments?.filter(row => row.environment?.id && row.status?.toLowerCase() !== 'undeployed') ?? []
const releaseRows = releaseHistory?.data?.filter(row => row.id) ?? []
const canCreateRelease = overviewApp?.canCreateRelease ?? true
const action = (
<Button nativeButton={false} size="small" variant="secondary" render={<Link href={`/deployments/${appInstanceId}/deploy`} />}>
{t('overview.viewDeployments')}
<span className="i-ri-arrow-right-up-line size-3.5" />
</Button>
)
if (!overviewApp?.id)
return null
if (overviewQuery.isLoading || releaseHistoryQuery.isLoading) {
return (
<Section title={t('overview.deploymentStatus')} action={action}>
<StatusRowsSkeleton />
</Section>
)
}
if (overviewQuery.isError || releaseHistoryQuery.isError) {
return (
<Section title={t('overview.deploymentStatus')} action={action}>
<SectionState>{t('common.loadFailed')}</SectionState>
</Section>
)
}
if (!overviewApp?.id) {
return (
<Section title={t('overview.deploymentStatus')} action={action}>
<SectionState>{t('detail.notFound')}</SectionState>
</Section>
)
}
return (
<Section
title={t('overview.deploymentStatus')}
action={(
<Button nativeButton={false} size="small" variant="secondary" render={<Link href={`/deployments/${appInstanceId}/deploy`} />}>
{t('overview.viewDeployments')}
<span className="i-ri-arrow-right-up-line size-3.5" />
</Button>
)}
action={action}
>
{deployments.length === 0
? (
@ -178,7 +284,7 @@ function DeploymentStatusSection({ appInstanceId }: {
: (
<div className="flex flex-col divide-y divide-divider-subtle">
{deployments.map((row) => {
const status = overviewDeploymentStatus(row.status)
const status = deploymentStatus(row)
return (
<div key={row.environment?.id} className="flex items-center justify-between gap-3 py-2">
<div className="flex min-w-0 flex-col">
@ -202,26 +308,58 @@ function AccessStatusSection({ appInstanceId }: {
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId } }
const { data: overview } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input,
}))
const { data: accessConfig } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input,
}))
const overview = overviewQuery.data
const accessConfig = accessConfigQuery.data
const webappAccessUrl = webappUrl(overview?.access?.webappUrl)
const cliUrl = overview?.access?.cliUrl
const apiUrl = overview?.access?.apiUrl ?? accessConfig?.developerApi?.apiUrl
const apiKeysCount = overview?.access?.apiKeyCount ?? accessConfig?.developerApi?.apiKeys?.length ?? 0
const skeletonRows = [
{ key: 'webapp', label: t('overview.webapp'), hintClassName: 'w-28' },
{ key: 'cli', label: t('overview.cli'), hintClassName: 'w-44' },
{ key: 'api', label: t('overview.api'), hintClassName: 'w-64', showMeta: true },
]
const action = (
<Button nativeButton={false} size="small" variant="secondary" render={<Link href={`/deployments/${appInstanceId}/settings`} />}>
{t('overview.configureAccess')}
<span className="i-ri-arrow-right-up-line size-3.5" />
</Button>
)
if (overviewQuery.isLoading || accessConfigQuery.isLoading) {
return (
<Section title={t('overview.accessStatus')} action={action}>
<AccessRowsSkeleton rows={skeletonRows} />
</Section>
)
}
if (overviewQuery.isError || accessConfigQuery.isError) {
return (
<Section title={t('overview.accessStatus')} action={action}>
<SectionState>{t('common.loadFailed')}</SectionState>
</Section>
)
}
if (!overview?.instance?.id) {
return (
<Section title={t('overview.accessStatus')} action={action}>
<SectionState>{t('detail.notFound')}</SectionState>
</Section>
)
}
return (
<Section
title={t('overview.accessStatus')}
action={(
<Button nativeButton={false} size="small" variant="secondary" render={<Link href={`/deployments/${appInstanceId}/settings`} />}>
{t('overview.configureAccess')}
<span className="i-ri-arrow-right-up-line size-3.5" />
</Button>
)}
action={action}
>
<div className="flex flex-col divide-y divide-divider-subtle">
<AccessOverviewRow

View File

@ -14,20 +14,56 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Textarea from '@/app/components/base/textarea'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { isUndeployedDeploymentRow } from '../runtime-status'
import { AccessChannelsSection } from './access-tab/channels-section'
import { DeveloperApiSection } from './access-tab/developer-api-section'
import { AccessPermissionsSection } from './access-tab/permissions-section'
import { Section, SectionState } from './common'
import { AccessChannelsSection } from './settings-tab/access/channels-section'
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
type SettingsFormProps = {
app: AppInstanceBasicInfo
settings?: GetAppInstanceSettingsReply
type AppInstanceWithId = AppInstanceBasicInfo & { id: string }
const SETTINGS_FORM_SKELETON_FIELDS = [
{ key: 'name', inputClassName: 'my-0 h-8 w-full animate-pulse rounded-lg' },
{ key: 'description', inputClassName: 'my-0 h-24 w-full animate-pulse rounded-lg' },
]
function SettingsFormSkeleton() {
return (
<div className="flex flex-col gap-3">
{SETTINGS_FORM_SKELETON_FIELDS.map(field => (
<div key={field.key} className="flex flex-col gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className={field.inputClassName} />
</div>
))}
<SkeletonRow className="justify-end gap-2">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-16 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function DeleteInstanceSkeleton() {
return (
<div className="flex flex-col gap-3 rounded-xl border border-util-colors-red-red-200 bg-util-colors-red-red-50 p-4">
<SkeletonRectangle className="h-3.5 w-20 animate-pulse" />
<SkeletonRectangle className="h-3 w-3/5 animate-pulse" />
<SkeletonRow className="items-center justify-between gap-2">
<SkeletonRectangle className="h-3 w-48 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
type DeleteInstanceControlProps = {
app: AppInstanceBasicInfo
app: AppInstanceWithId
settings?: GetAppInstanceSettingsReply
hasDeployments: boolean
}
@ -40,35 +76,31 @@ function DeleteInstanceButton({
const { t } = useTranslation('deployments')
const router = useRouter()
const deleteInstance = useMutation(consoleQuery.enterprise.appDeploy.deleteAppInstance.mutationOptions())
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const appInstanceId = app.id
const appName = app.name ?? appInstanceId ?? ''
const appName = app.name ?? appInstanceId
const canDelete = !hasDeployments && Boolean(settings) && settings?.deleteGuard?.canDelete !== false
const handleDelete = () => {
if (!appInstanceId)
return
void (async () => {
setIsDeleting(true)
try {
await deleteInstance.mutateAsync({
params: {
appInstanceId,
},
})
toast.success(t('settings.deleted'))
router.push('/deployments')
}
catch {
toast.error(t('settings.deleteFailed'))
}
finally {
setIsDeleting(false)
setShowDeleteConfirm(false)
}
})()
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
{
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
onSettled: () => {
setShowDeleteConfirm(false)
},
},
)
}
return (
@ -76,7 +108,7 @@ function DeleteInstanceButton({
<Button
variant="primary"
tone="destructive"
disabled={!canDelete || isDeleting}
disabled={!canDelete || deleteInstance.isPending}
onClick={() => setShowDeleteConfirm(true)}
>
{t('settings.delete')}
@ -135,112 +167,139 @@ function DeleteInstanceControl({
)
}
function SettingsForm({ app, settings }: SettingsFormProps) {
function SettingsForm({ app, settings }: {
app: AppInstanceWithId
settings?: GetAppInstanceSettingsReply
}) {
const { t } = useTranslation('deployments')
const updateInstance = useMutation(consoleQuery.enterprise.appDeploy.updateAppInstance.mutationOptions())
const appName = app.name ?? app.id ?? ''
const appName = app.name ?? app.id
const [name, setName] = useState(settings?.name ?? appName)
const [description, setDescription] = useState(settings?.description ?? app.description ?? '')
const [isSaving, setIsSaving] = useState(false)
const initialName = settings?.name ?? appName
const initialDescription = settings?.description ?? app.description ?? ''
const canSave = Boolean(name.trim() && (name !== initialName || description !== initialDescription) && !isSaving)
const canSave = Boolean(name.trim() && (name !== initialName || description !== initialDescription) && !updateInstance.isPending)
const handleSave = () => {
const appInstanceId = app.id
if (!canSave || !appInstanceId)
if (!canSave)
return
void (async () => {
setIsSaving(true)
try {
await updateInstance.mutateAsync({
params: {
appInstanceId,
},
body: {
name: name.trim(),
description: description.trim() || undefined,
},
})
toast.success(t('settings.updated'))
}
catch {
toast.error(t('settings.updateFailed'))
}
finally {
setIsSaving(false)
}
})()
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
name: name.trim(),
description: description.trim() || undefined,
},
},
{
onSuccess: () => {
toast.success(t('settings.updated'))
},
onError: () => {
toast.error(t('settings.updateFailed'))
},
},
)
}
return (
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
<div className="system-sm-semibold text-text-primary">{t('settings.general')}</div>
<div className="system-xs-regular text-text-tertiary">{t('settings.descriptionHelp')}</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-name">
{t('settings.name')}
</label>
<input
id="settings-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="flex h-8 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 system-sm-medium text-text-secondary outline-hidden placeholder:text-text-quaternary"
/>
<Section title={t('settings.general')} description={t('settings.descriptionHelp')}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-name">
{t('settings.name')}
</label>
<Input
id="settings-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="h-8"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-desc">
{t('settings.description')}
</label>
<Textarea
id="settings-desc"
value={description}
onChange={e => setDescription(e.target.value)}
className="min-h-24"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
disabled={updateInstance.isPending || (name === initialName && description === initialDescription)}
onClick={() => {
setName(initialName)
setDescription(initialDescription)
}}
>
{t('settings.reset')}
</Button>
<Button variant="primary" disabled={!canSave} onClick={handleSave}>
{t('settings.save')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-desc">
{t('settings.description')}
</label>
<textarea
id="settings-desc"
value={description}
onChange={e => setDescription(e.target.value)}
className="min-h-24 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2 system-sm-regular text-text-secondary outline-hidden placeholder:text-text-quaternary"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
disabled={isSaving || (name === initialName && description === initialDescription)}
onClick={() => {
setName(initialName)
setDescription(initialDescription)
}}
>
{t('settings.reset')}
</Button>
<Button variant="primary" disabled={!canSave} onClick={handleSave}>
{t('settings.save')}
</Button>
</div>
</div>
</Section>
)
}
function SettingsFormSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const { data: overview } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input: appInput,
}))
const overview = overviewQuery.data
const app = overview?.instance
const settingsQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceSettings.queryOptions({
input: appInput,
}))
if (!app?.id)
return null
if (overviewQuery.isLoading || settingsQuery.isLoading) {
return (
<Section title={t('settings.general')} description={t('settings.descriptionHelp')}>
<SettingsFormSkeleton />
</Section>
)
}
if (overviewQuery.isError || settingsQuery.isError) {
return (
<Section title={t('settings.general')} description={t('settings.descriptionHelp')}>
<SectionState>{t('common.loadFailed')}</SectionState>
</Section>
)
}
if (!app?.id) {
return (
<Section title={t('settings.general')} description={t('settings.descriptionHelp')}>
<SectionState>{t('detail.notFound')}</SectionState>
</Section>
)
}
const appName = app.name ?? app.id
const formKey = `${app.id}-${settingsQuery.data?.name ?? appName}-${settingsQuery.data?.description ?? app.description ?? ''}`
const appWithId = {
...app,
id: app.id,
}
return (
<SettingsForm
key={formKey}
app={app}
app={appWithId}
settings={settingsQuery.data}
/>
)
@ -249,26 +308,52 @@ function SettingsFormSection({ appInstanceId }: {
function DeleteInstanceControlSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const { data: overview } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input: appInput,
}))
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
const overview = overviewQuery.data
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input: appInput,
}))
const settingsQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceSettings.queryOptions({
input: appInput,
}))
const environmentDeployments = environmentDeploymentsQuery.data
const app = overview?.instance
if (!app?.id)
return null
if (overviewQuery.isLoading || environmentDeploymentsQuery.isLoading || settingsQuery.isLoading) {
return <DeleteInstanceSkeleton />
}
if (overviewQuery.isError || environmentDeploymentsQuery.isError || settingsQuery.isError) {
return (
<div className="flex flex-col gap-3 rounded-xl border border-util-colors-red-red-200 bg-util-colors-red-red-50 p-4">
<div className="system-sm-semibold text-util-colors-red-red-700">{t('settings.danger')}</div>
<SectionState>{t('common.loadFailed')}</SectionState>
</div>
)
}
if (!app?.id) {
return (
<div className="flex flex-col gap-3 rounded-xl border border-util-colors-red-red-200 bg-util-colors-red-red-50 p-4">
<div className="system-sm-semibold text-util-colors-red-red-700">{t('settings.danger')}</div>
<SectionState>{t('detail.notFound')}</SectionState>
</div>
)
}
const hasDeployments = environmentDeployments?.data?.some(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? false
const appWithId = {
...app,
id: app.id,
}
return (
<DeleteInstanceControl
app={app}
app={appWithId}
settings={settingsQuery.data}
hasDeployments={hasDeployments}
/>

View File

@ -12,7 +12,7 @@ import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../environment'
import { environmentName } from '../../../environment'
function ApiKeyRow({ appInstanceId, apiKey }: {
appInstanceId: string

View File

@ -0,0 +1,197 @@
'use client'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import { webappUrl } from '../../../webapp-url'
import { Section, SectionState } from '../../common'
import { CopyPill, EndpointRow } from './common'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
{ key: 'webapp', className: 'flex flex-col gap-2' },
{ key: 'cli', className: 'flex flex-col gap-2 border-t border-divider-subtle pt-3' },
]
function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
appInstanceId: string
checked: boolean
disabled?: boolean
}) {
const toggleAccessChannel = useMutation(consoleQuery.enterprise.appDeploy.updateAccessChannels.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(enabled) => {
toggleAccessChannel.mutate({
params: { appInstanceId },
body: { enabled },
})
}}
/>
)
}
function AccessChannelsSkeleton() {
return (
<div className="flex flex-col gap-5">
{ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => (
<div
key={section.key}
className={section.className}
>
<SkeletonRow className="items-center gap-2">
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-5 w-24 animate-pulse rounded-full" />
</SkeletonRow>
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRow className="flex-wrap items-center gap-x-3 gap-y-1.5">
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 min-w-65 flex-1 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
))}
</div>
)
}
export function AccessChannelsSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessConfig = accessConfigQuery.data
const runEnabled = accessConfig?.accessChannels?.enabled ?? false
const webappRows = accessConfig?.accessChannels?.webappRows?.filter(row => row.url) ?? []
const cliDomain = getUrlOrigin(accessConfig?.accessChannels?.cli?.url)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
action={(
accessConfigQuery.isLoading
? <SwitchSkeleton />
: (
<AccessChannelsSwitch
appInstanceId={appInstanceId}
checked={runEnabled}
disabled={accessConfigQuery.isError}
/>
)
)}
>
{accessConfigQuery.isLoading
? <AccessChannelsSkeleton />
: accessConfigQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: runEnabled
? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.runAccess.webapp')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.runAccess.webappDesc')}
</div>
{webappRows.length > 0
? (
<div className="flex flex-col gap-2">
{webappRows.map((row) => {
const endpointUrl = webappUrl(row.url)
return (
<EndpointRow
key={`webapp-${row.environment?.id ?? row.url}`}
envName={environmentName(row.environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{t('access.runAccess.webappEmpty')}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 border-t border-divider-subtle pt-3">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.cli.title')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.description')}
</div>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-65 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>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
</div>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.channels.disabled')}
</div>
)}
</Section>
)
}

View File

@ -0,0 +1,206 @@
'use client'
import type {
ConsoleEnvironment,
EnvironmentAccessRow,
} from '@dify/contracts/enterprise/types.gen'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { Section, SectionState } from '../../common'
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
import { CopyPill } from './common'
type CreatedApiToken = {
appInstanceId: string
token: string
}
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
function permissionEnvironment(row: EnvironmentAccessRow): ConsoleEnvironment | undefined {
return row.environment?.id ? row.environment : undefined
}
function DeveloperApiSwitch({ appInstanceId, checked, disabled }: {
appInstanceId: string
checked: boolean
disabled?: boolean
}) {
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.appDeploy.updateDeveloperApi.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(enabled) => {
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: { enabled },
})
}}
/>
)
}
function CreatedApiTokenCard({ token, onDismiss }: {
token: string
onDismiss: () => void
}) {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.newTokenTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</span>
</div>
<button
type="button"
onClick={onDismiss}
aria-label={t('access.api.dismissToken')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-close-line size-3.5" />
</button>
</div>
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
</div>
)
}
function DeveloperApiSkeleton() {
return (
<div className="flex flex-col gap-2">
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
<SkeletonRow className="items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3.5 w-28 animate-pulse" />
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
<div className="flex flex-col divide-y divide-divider-subtle">
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="items-center gap-3 py-1.5">
<div className="flex min-w-35 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 min-w-0 flex-1 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
</div>
)
}
export function DeveloperApiSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessConfig = accessConfigQuery.data
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
const apiUrl = accessConfig?.developerApi?.apiUrl
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
const environments = accessConfig?.permissions
?.map(permissionEnvironment)
.filter((environment): environment is ConsoleEnvironment => Boolean(environment)) ?? []
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
return (
<Section
title={t('access.api.developerTitle')}
description={t('access.api.description')}
action={(
accessConfigQuery.isLoading
? <SwitchSkeleton />
: (
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
disabled={accessConfigQuery.isError}
/>
)
)}
>
{accessConfigQuery.isLoading
? <DeveloperApiSkeleton />
: accessConfigQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: apiEnabled
? (
<div className="flex flex-col gap-2">
{apiUrl && (
<CopyPill
label={t('access.api.endpoint')}
value={apiUrl}
/>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.backendTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.keyList')}
</span>
</div>
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
apiKeys={apiKeys}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
</div>
{visibleCreatedApiToken && (
<CreatedApiTokenCard
token={visibleCreatedApiToken}
onDismiss={() => setCreatedApiToken(undefined)}
/>
)}
{apiKeys.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{environments.length === 0
? t('access.api.empty')
: t('access.api.noKeys')}
</div>
)
: (
<ApiKeyList
appInstanceId={appInstanceId}
apiKeys={apiKeys}
/>
)}
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.api.disabled')}
</div>
)}
</Section>
)
}

View File

@ -0,0 +1,77 @@
'use client'
import type {
EnvironmentAccessRow,
} from '@dify/contracts/enterprise/types.gen'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { Section, SectionState } from '../../common'
import { EnvironmentPermissionRow } from './permissions'
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow & {
environment: NonNullable<EnvironmentAccessRow['environment']>
} {
return Boolean(row.environment?.id)
}
function AccessPermissionsSkeleton() {
return (
<div className="flex flex-col gap-3">
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="flex-wrap items-center gap-x-3 gap-y-1.5">
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-55 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
export function AccessPermissionsSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessConfig = accessConfigQuery.data
const permissionRows = accessConfig?.permissions?.filter(hasEnvironment) ?? []
return (
<Section
title={t('access.permissions.title')}
description={t('access.permissions.description')}
>
{accessConfigQuery.isLoading
? <AccessPermissionsSkeleton />
: accessConfigQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: permissionRows.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
{t('access.runAccess.noEnvs')}
</div>
)
: (
<div className="flex flex-col gap-3">
{permissionRows.map(row => (
<EnvironmentPermissionRow
key={row.environment.id}
appInstanceId={appInstanceId}
environment={row.environment}
summaryPolicy={row}
/>
))}
</div>
)}
</Section>
)
}

View File

@ -20,8 +20,10 @@ import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../environment'
import { environmentName } from '../../../environment'
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
@ -130,6 +132,8 @@ function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>
return `${subject.subjectType}:${subject.id}`
}
const SUBJECT_PICKER_SKELETON_KEYS = ['first-subject', 'second-subject', 'third-subject']
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
return subjects.map(subject => ({
subjectId: subject.id,
@ -203,15 +207,14 @@ function SubjectPicker({
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
const selectedKeys = new Set(selectedSubjects.map(subjectKey))
const subjectsQuery = useQuery(consoleQuery.enterprise.appDeploy.searchAccessSubjects.queryOptions({
input: open
? {
params: { appInstanceId },
query: {
keyword: debouncedKeyword.trim() || undefined,
subjectTypes: ['account', 'group'],
},
}
: skipToken,
input: {
params: { appInstanceId },
query: {
keyword: debouncedKeyword.trim() || undefined,
subjectTypes: ['account', 'group'],
},
},
enabled: open,
}))
const subjects = subjectsQuery.data?.data
?.map(normalizeSubject)
@ -249,21 +252,23 @@ function SubjectPicker({
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-90 p-0">
<div className="flex max-h-105 flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="border-b border-divider-subtle p-2">
<div className="flex h-8 items-center gap-2 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder={t('access.members.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent system-sm-regular text-text-primary outline-hidden placeholder:text-text-quaternary"
/>
</div>
<Input
showLeftIcon
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder={t('access.members.searchPlaceholder')}
className="h-8"
/>
</div>
<div className="min-h-10 overflow-y-auto p-1">
{subjectsQuery.isLoading
? (
<div className="flex h-16 items-center justify-center">
<span className="size-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
<div className="flex flex-col gap-2 px-3 py-3">
{SUBJECT_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-6">
<SkeletonRectangle className="h-3 w-full animate-pulse" />
</SkeletonRow>
))}
</div>
)
: subjects.length === 0
@ -344,20 +349,20 @@ export function EnvironmentPermissionRow({
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const [isSaving, setIsSaving] = useState(false)
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
const isSaving = setEnvironmentAccessPolicy.isPending
const controlsDisabled = isSaving || policyQuery.isLoading || policyQuery.isError
const persistPolicy = async (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
const persistPolicy = (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
if (!environmentId)
return
if (nextKind === 'specific' && nextSubjects.length === 0)
return
setIsSaving(true)
try {
await setEnvironmentAccessPolicy.mutateAsync({
setEnvironmentAccessPolicy.mutate(
{
params: {
appInstanceId,
environmentId,
@ -366,15 +371,16 @@ export function EnvironmentPermissionRow({
accessMode: permissionKeyToAccessMode(nextKind),
subjects: nextKind === 'specific' ? policySubjects(nextSubjects) : [],
},
})
setDraft({})
}
catch {
toast.error(t('access.permission.updateFailed'))
}
finally {
setIsSaving(false)
}
},
{
onSuccess: () => {
setDraft({})
},
onError: () => {
toast.error(t('access.permission.updateFailed'))
},
},
)
}
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
@ -384,10 +390,10 @@ export function EnvironmentPermissionRow({
subjects: nextKind === 'specific' ? subjects : [],
})
if (nextKind === 'specific') {
void persistPolicy(nextKind, subjects)
persistPolicy(nextKind, subjects)
return
}
void persistPolicy(nextKind, [])
persistPolicy(nextKind, [])
}
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
@ -398,7 +404,7 @@ export function EnvironmentPermissionRow({
kind: 'specific',
subjects: nextSubjects,
})
void persistPolicy('specific', nextSubjects)
persistPolicy('specific', nextSubjects)
}
return (
@ -407,19 +413,28 @@ export function EnvironmentPermissionRow({
<span className="min-w-35 system-xs-regular text-text-tertiary">
{environmentName(environment)}
</span>
<PermissionPicker
value={permissionKind}
disabled={isSaving || policyQuery.isLoading}
onChange={handlePermissionChange}
/>
{policyQuery.isLoading
? <SkeletonRectangle className="my-0 h-8 w-55 animate-pulse rounded-lg" />
: (
<PermissionPicker
value={permissionKind}
disabled={controlsDisabled}
onChange={handlePermissionChange}
/>
)}
</div>
{policyQuery.isError && (
<div className="system-xs-regular text-text-destructive">
{t('common.loadFailed')}
</div>
)}
{permissionKind === 'specific' && (
<div className="flex flex-col gap-2 pl-0 sm:pl-38">
<div className="flex flex-wrap items-center gap-2">
<SubjectPicker
appInstanceId={appInstanceId}
selectedSubjects={subjects}
disabled={isSaving || policyQuery.isLoading}
disabled={controlsDisabled}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
@ -434,7 +449,7 @@ export function EnvironmentPermissionRow({
<SubjectPill
key={subjectKey(subject)}
subject={subject}
disabled={isSaving || subjects.length <= 1}
disabled={controlsDisabled || subjects.length <= 1}
onRemove={() => handleSubjectsChange(subjects.filter(item => subjectKey(item) !== subjectKey(subject)))}
/>
))}

View File

@ -22,7 +22,7 @@ function CreateReleaseControl({ appInstanceId }: {
}))
const canCreateRelease = overview ? overview.instance?.canCreateRelease ?? true : false
async function handleCreateRelease(form: HTMLFormElement) {
function handleCreateRelease(form: HTMLFormElement) {
if (!canCreateRelease || createRelease.isPending)
return
@ -32,8 +32,8 @@ function CreateReleaseControl({ appInstanceId }: {
if (!releaseName)
return
try {
const response = await createRelease.mutateAsync({
createRelease.mutate(
{
params: {
appInstanceId,
},
@ -41,15 +41,21 @@ function CreateReleaseControl({ appInstanceId }: {
name: releaseName,
description: releaseDescription || undefined,
},
})
if (!response.release?.id)
throw new Error('Create release did not return a release.')
form.reset()
setIsCreating(false)
}
catch {
toast.error(t('versions.createFailed'))
}
},
{
onSuccess: (response) => {
if (!response.release?.id) {
toast.error(t('versions.createFailed'))
return
}
form.reset()
setIsCreating(false)
},
onError: () => {
toast.error(t('versions.createFailed'))
},
},
)
}
return (
@ -64,88 +70,93 @@ function CreateReleaseControl({ appInstanceId }: {
{t('versions.createRelease')}
</Button>
<Dialog open={isCreating} onOpenChange={setIsCreating}>
<Dialog
open={isCreating}
onOpenChange={setIsCreating}
>
<DialogContent className="w-140 overflow-hidden p-0">
<DialogCloseButton />
<form
onSubmit={(event) => {
event.preventDefault()
void handleCreateRelease(event.currentTarget)
}}
>
<div className="flex items-start gap-3 border-b border-divider-subtle px-6 py-5 pr-14">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-state-accent-hover text-text-accent">
<span className="i-ri-rocket-2-line size-5" />
</div>
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
</div>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-name">
{t('versions.releaseNameLabel')}
</label>
<Input
id="release-name"
name="name"
placeholder={t('versions.releaseNamePlaceholder')}
maxLength={128}
required
autoFocus
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-description">
{t('versions.releaseDescriptionLabel')}
</label>
<span className="system-xs-regular text-text-quaternary">
{t('versions.optional')}
</span>
{isCreating && (
<form
onSubmit={(event) => {
event.preventDefault()
handleCreateRelease(event.currentTarget)
}}
>
<div className="flex items-start gap-3 border-b border-divider-subtle px-6 py-5 pr-14">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-state-accent-hover text-text-accent">
<span className="i-ri-rocket-2-line size-5" />
</div>
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
</div>
<textarea
id="release-description"
name="description"
placeholder={t('versions.releaseDescriptionPlaceholder')}
maxLength={512}
className="min-h-24 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('versions.createReleaseHint')}
<div className="flex flex-col gap-5 px-6 py-5">
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-name">
{t('versions.releaseNameLabel')}
</label>
<Input
id="release-name"
name="name"
placeholder={t('versions.releaseNamePlaceholder')}
maxLength={128}
required
autoFocus
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-description">
{t('versions.releaseDescriptionLabel')}
</label>
<span className="system-xs-regular text-text-quaternary">
{t('versions.optional')}
</span>
</div>
<textarea
id="release-description"
name="description"
placeholder={t('versions.releaseDescriptionPlaceholder')}
maxLength={512}
className="min-h-24 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
</div>
<div className="flex shrink-0 justify-end gap-2">
<Button
type="button"
variant="secondary"
disabled={createRelease.isPending}
onClick={() => setIsCreating(false)}
>
{t('versions.cancelCreate')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-22"
disabled={!canCreateRelease || createRelease.isPending}
>
{createRelease.isPending ? t('versions.creating') : t('versions.create')}
</Button>
<div className="flex items-center justify-between gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('versions.createReleaseHint')}
</div>
<div className="flex shrink-0 justify-end gap-2">
<Button
type="button"
variant="secondary"
disabled={createRelease.isPending}
onClick={() => setIsCreating(false)}
>
{t('versions.cancelCreate')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-22"
disabled={!canCreateRelease || createRelease.isPending}
>
{createRelease.isPending ? t('versions.creating') : t('versions.create')}
</Button>
</div>
</div>
</div>
</form>
</form>
)}
</DialogContent>
</Dialog>
</>

View File

@ -44,7 +44,10 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
})) ?? []
const environments = environmentOptions.filter(env => env.id)
const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find(release => release.id === releaseId) ?? { id: releaseId }
const targetRelease = releaseRows.find(release => release.id === releaseId)
if (!targetRelease)
return null
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>

View File

@ -2,12 +2,16 @@
import type { ReleaseRow, RuntimeInstanceRow } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import type { ReleaseDeployment } from './release-deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQuery } from '@tanstack/react-query'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../data'
import {
formatDate,
releaseCommit,
@ -19,6 +23,8 @@ import { DeployedToBadge } from './deployed-to-badge'
import { getReleaseDeployments } from './release-deployments'
const GRID_TEMPLATE = 'grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1.5fr)_96px]'
const RELEASE_TABLE_HEADER_SKELETON_KEYS = ['version', 'description', 'author', 'deployments', 'actions']
const RELEASE_TABLE_ROW_SKELETON_KEYS = ['latest', 'previous', 'older', 'archived', 'initial']
type ReleaseRowWithId = ReleaseRow & {
id: string
@ -38,10 +44,94 @@ function ReleaseHistoryTableState({ children }: {
)
}
function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows }: {
function ReleaseHistoryTableSkeleton() {
return (
<div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg">
<div className={cn(
'hidden items-center gap-4 border-b border-divider-subtle px-4 py-3 pc:grid',
GRID_TEMPLATE,
)}
>
{RELEASE_TABLE_HEADER_SKELETON_KEYS.map(key => (
<SkeletonRectangle key={key} className="h-3 w-20 animate-pulse" />
))}
</div>
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className="border-b border-divider-subtle last:border-b-0">
<div className="flex flex-col gap-3 p-4 pc:hidden">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRow className="mt-1 gap-2">
<SkeletonRectangle className="h-3 w-28 animate-pulse" />
<SkeletonRectangle className="h-3 w-20 animate-pulse" />
</SkeletonRow>
</div>
<SkeletonRectangle className="my-0 h-7 w-8 animate-pulse rounded-lg" />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<SkeletonRectangle className="h-3 w-20 animate-pulse" />
<ReleaseDeploymentsSkeleton />
</div>
</div>
<div className={cn('hidden items-center gap-4 px-4 py-3 pc:grid', GRID_TEMPLATE)}>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<ReleaseDeploymentsSkeleton />
<div className="hidden justify-end pc:flex">
<SkeletonRectangle className="my-0 h-7 w-8 animate-pulse rounded-lg" />
</div>
</div>
</div>
))}
</div>
)
}
function ReleaseDeploymentsSkeleton() {
return (
<SkeletonRow className="gap-1">
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
</SkeletonRow>
)
}
function ReleaseDeploymentsContent({
items,
isLoading,
hasError,
loadFailedLabel,
}: {
items: ReleaseDeployment[]
isLoading?: boolean
hasError?: boolean
loadFailedLabel: string
}) {
if (isLoading)
return <ReleaseDeploymentsSkeleton />
if (hasError)
return <span className="system-sm-regular text-text-tertiary">{loadFailedLabel}</span>
if (items.length === 0)
return <span className="system-sm-regular text-text-quaternary"></span>
return items.map(item => (
<DeployedToBadge
key={`${item.environmentId}-${item.state}`}
item={item}
/>
))
}
function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError }: {
appInstanceId: string
releaseRows: ReleaseRowWithId[]
deploymentRows: RuntimeInstanceRow[]
deployedToLoading?: boolean
deployedToHasError?: boolean
}) {
const { t } = useTranslation('deployments')
@ -62,6 +152,7 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows }: {
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
return (
<div key={release.id} className="border-b border-divider-subtle last:border-b-0">
<div className="flex flex-col gap-3 p-4 pc:hidden">
@ -94,14 +185,12 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows }: {
{t('versions.col.deployedTo')}
</div>
<div className="flex min-w-0 flex-wrap gap-1">
{releaseDeployments.length === 0
? <span className="system-sm-regular text-text-quaternary"></span>
: releaseDeployments.map(item => (
<DeployedToBadge
key={`${item.environmentId}-${item.state}`}
item={item}
/>
))}
<ReleaseDeploymentsContent
items={releaseDeployments}
isLoading={deployedToLoading}
hasError={deployedToHasError}
loadFailedLabel={t('common.loadFailed')}
/>
</div>
</div>
</div>
@ -127,14 +216,12 @@ function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows }: {
<div className="system-sm-regular text-text-secondary">{formatDate(release.createdAt)}</div>
<div className="system-sm-regular text-text-secondary">{row.createdBy?.name ?? '—'}</div>
<div className="flex flex-wrap gap-1">
{releaseDeployments.length === 0
? <span className="system-sm-regular text-text-quaternary"></span>
: releaseDeployments.map(item => (
<DeployedToBadge
key={`${item.environmentId}-${item.state}`}
item={item}
/>
))}
<ReleaseDeploymentsContent
items={releaseDeployments}
isLoading={deployedToLoading}
hasError={deployedToHasError}
loadFailedLabel={t('common.loadFailed')}
/>
</div>
<div className="flex justify-end gap-1">
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
@ -151,6 +238,7 @@ export function ReleaseHistoryTable({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [currentPage, setCurrentPage] = useState(0)
const input = { params: { appInstanceId } }
const overviewQuery = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input,
@ -159,12 +247,14 @@ export function ReleaseHistoryTable({ appInstanceId }: {
input: {
...input,
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
pageNumber: currentPage + 1,
resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
},
},
placeholderData: keepPreviousData,
}))
const releaseRows = releaseHistoryQuery.data?.data?.filter(hasReleaseId) ?? []
const totalReleases = releaseHistoryQuery.data?.pagination?.totalCount ?? releaseRows.length
const shouldLoadRuntimeInstances = releaseRows.length > 0
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({
input,
@ -172,20 +262,21 @@ export function ReleaseHistoryTable({ appInstanceId }: {
}))
const isLoading = releaseHistoryQuery.isLoading
|| (releaseRows.length === 0 && overviewQuery.isLoading)
|| (shouldLoadRuntimeInstances && environmentDeploymentsQuery.isLoading)
const hasError = releaseHistoryQuery.isError
|| (releaseRows.length === 0 && overviewQuery.isError)
const deployedToLoading = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isLoading
const deployedToHasError = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isError
const sourceAppUnavailable = overviewQuery.data?.instance?.canCreateRelease === false
const deploymentRows = environmentDeploymentsQuery.data?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
if (isLoading) {
return <ReleaseHistoryTableSkeleton />
}
if (hasError) {
return (
<ReleaseHistoryTableState>
<span
aria-label={t('versions.releaseHistory')}
className="inline-flex items-center"
role="status"
>
<span className="size-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
</span>
{t('common.loadFailed')}
</ReleaseHistoryTableState>
)
}
@ -199,10 +290,23 @@ export function ReleaseHistoryTable({ appInstanceId }: {
}
return (
<ReleaseHistoryRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
/>
<div className="flex flex-col gap-3">
<ReleaseHistoryRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
deployedToLoading={deployedToLoading}
deployedToHasError={deployedToHasError}
/>
{totalReleases > RELEASE_HISTORY_PAGE_SIZE && (
<Pagination
className="rounded-xl border border-components-panel-border bg-components-panel-bg"
current={currentPage}
total={totalReleases}
limit={RELEASE_HISTORY_PAGE_SIZE}
onChange={setCurrentPage}
/>
)}
</div>
)
}

View File

@ -1,17 +1,72 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { debounce, useQueryState } from 'nuqs'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { CreateInstanceModal } from '../components/create-instance-modal'
import { SOURCE_APPS_PAGE_SIZE } from '../data'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
import { NewInstanceCard } from './new-instance-card'
import { envFilterQueryState, keywordsQueryState } from './query-state'
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
function DeploymentsListState({ children }: {
children: ReactNode
}) {
return (
<div className="col-span-full rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
function InstanceCardSkeleton() {
return (
<div className="relative col-span-1 inline-flex h-40 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
<div className="relative shrink-0">
<SkeletonRectangle className="my-0 size-10 animate-pulse rounded-lg" />
<SkeletonRectangle className="absolute -right-0.5 -bottom-0.5 my-0 size-4 animate-pulse rounded-sm shadow-xs" />
</div>
<div className="flex w-0 grow flex-col gap-1.5 py-px">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</div>
<div className="flex grow flex-col gap-2 px-3.5">
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-3/4 animate-pulse" />
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-12 pb-1.5 pl-3.5">
<div className="flex min-w-0 grow items-center gap-1.5">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
</div>
<div className="absolute right-1.5 bottom-1 flex h-10.5 w-8 items-center justify-center">
<SkeletonRectangle className="my-0 h-1 w-4 animate-pulse rounded-full" />
</div>
</div>
)
}
function DeploymentsListSkeleton() {
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
<InstanceCardSkeleton key={key} />
))
}
function DeploymentsSearchInput() {
const { t } = useTranslation('deployments')
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
@ -46,51 +101,90 @@ function DeploymentsListControls() {
)
}
function DeploymentsList() {
export function DeploymentsList() {
const { t } = useTranslation('deployments')
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const queryKeywords = keywords.trim()
const requestedEnvironmentId = envFilter !== 'all' && envFilter !== 'not-deployed'
? envFilter
: undefined
const listQuery = useQuery(consoleQuery.enterprise.appDeploy.listAppInstances.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(requestedEnvironmentId ? { environmentId: requestedEnvironmentId } : {}),
...(envFilter === 'not-deployed' ? { notDeployed: true } : {}),
...(queryKeywords ? { query: queryKeywords } : {}),
},
},
placeholderData: prev => prev,
}))
const apps = listQuery.data?.data ?? []
const {
data,
error,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
...consoleQuery.enterprise.appDeploy.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(requestedEnvironmentId ? { environmentId: requestedEnvironmentId } : {}),
...(envFilter === 'not-deployed' ? { notDeployed: true } : {}),
...(queryKeywords ? { query: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const pages = data?.pages ?? []
const apps = pages.flatMap(page => page.data ?? [])
const showSkeleton = isLoading || (isFetching && pages.length === 0)
useEffect(() => {
if (!hasNextPage || isLoading || isFetchingNextPage || error)
return
const anchor = anchorRef.current
const container = containerRef.current
if (!anchor || !container)
return
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting)
void fetchNextPage()
}, {
root: container,
rootMargin: '160px',
threshold: 0.1,
})
observer.observe(anchor)
return () => observer.disconnect()
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
return (
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<DeploymentsListControls />
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<NewInstanceCard />
{apps.map(app => (
<InstanceCard
key={app.id}
app={app}
/>
))}
{showSkeleton
? <DeploymentsListSkeleton />
: isError
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
: apps.length === 0
? <DeploymentsListState>{t('list.empty')}</DeploymentsListState>
: apps.map(app => (
<InstanceCard
key={app.id}
app={app}
/>
))}
{isFetchingNextPage && <DeploymentsListSkeleton />}
</div>
<div ref={anchorRef} className="h-0" />
<div className="py-4" />
</div>
)
}
export function DeploymentsMain() {
return (
<>
<DeploymentsList />
<CreateInstanceModal />
</>
)
}

View File

@ -1,18 +1,16 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { openCreateInstanceModalAtom } from '../store'
import { CreateInstanceModal } from '../components/create-instance-modal'
type NewInstanceActionProps = {
function NewInstanceAction({ icon, label, disabled, onClick }: {
icon: string
label: string
disabled?: boolean
onClick?: () => void
}
function NewInstanceAction({ icon, label, disabled, onClick }: NewInstanceActionProps) {
}) {
const { t } = useTranslation('deployments')
return (
@ -22,7 +20,7 @@ function NewInstanceAction({ icon, label, disabled, onClick }: NewInstanceAction
disabled={disabled}
title={disabled ? t('newInstance.comingSoon') : undefined}
className={cn(
'mb-1 flex h-8 w-full items-center gap-2 rounded-lg px-6 text-left system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
'flex h-8 w-full items-center gap-2 rounded-lg px-6 text-left system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
disabled
? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-text-tertiary'
: 'cursor-pointer',
@ -41,14 +39,17 @@ function NewInstanceAction({ icon, label, disabled, onClick }: NewInstanceAction
function CreateFromStudioAction() {
const { t } = useTranslation('deployments')
const openCreateInstanceModal = useSetAtom(openCreateInstanceModalAtom)
const [createModalOpen, setCreateModalOpen] = useState(false)
return (
<NewInstanceAction
icon="i-ri-stack-line"
label={t('newInstance.fromStudio')}
onClick={openCreateInstanceModal}
/>
<>
<NewInstanceAction
icon="i-ri-stack-line"
label={t('newInstance.fromStudio')}
onClick={() => setCreateModalOpen(true)}
/>
<CreateInstanceModal open={createModalOpen} onOpenChange={setCreateModalOpen} />
</>
)
}
@ -57,7 +58,7 @@ export function NewInstanceCard() {
return (
<div className="relative col-span-1 inline-flex h-40 flex-col justify-between rounded-xl border border-components-card-border bg-components-card-bg">
<div className="grow rounded-t-xl p-2">
<div className="flex grow flex-col gap-1 rounded-t-xl p-2">
<div className="px-6 pt-2 pb-1 text-xs/[18px] font-medium text-text-tertiary">
{t('newInstance.title')}
</div>

View File

@ -2,15 +2,15 @@
import type { AppInstanceBasicInfo, AppInstanceCard } from '@dify/contracts/enterprise/types.gen'
import type { NavItem } from '@/app/components/header/nav/nav-selector'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { keepPreviousData, skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Nav from '@/app/components/header/nav'
import { useParams, useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import { useParams, useSelectedLayoutSegment } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
import { SOURCE_APPS_PAGE_SIZE } from '../data'
import { openCreateInstanceModalAtom } from '../store'
import { CreateInstanceModal } from '../components/create-instance-modal'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
function navItemFromListApp(app: AppInstanceCard): NavItem[] {
if (!app.id || !app.name)
@ -48,31 +48,35 @@ function navItemFromOverview(instance?: AppInstanceBasicInfo): NavItem | undefin
export function DeploymentsNav() {
const { t } = useTranslation()
const router = useRouter()
const selectedSegment = useSelectedLayoutSegment()
const isActive = selectedSegment === 'deployments'
const params = useParams<{ appInstanceId?: string }>()
const appInstanceId = params?.appInstanceId
const [createModalOpen, setCreateModalOpen] = useState(false)
const openCreateInstanceModal = useSetAtom(openCreateInstanceModalAtom)
const { data: currentInstance } = useQuery(consoleQuery.enterprise.appDeploy.getAppInstanceOverview.queryOptions({
input: appInstanceId
? { params: { appInstanceId } }
: skipToken,
enabled: isActive && Boolean(appInstanceId),
enabled: isActive,
select: data => data.instance,
}))
const listQuery = useQuery(consoleQuery.enterprise.appDeploy.listAppInstances.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
},
},
const listQuery = useInfiniteQuery({
...consoleQuery.enterprise.appDeploy.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: isActive,
}))
const appNavItems = listQuery.data?.data?.flatMap(navItemFromListApp) ?? []
})
const appNavItems = listQuery.data?.pages.flatMap(page => page.data?.flatMap(navItemFromListApp) ?? []) ?? []
const currentNavItem = navItemFromOverview(currentInstance)
const navigationItems: NavItem[] = isActive
@ -86,23 +90,31 @@ export function DeploymentsNav() {
: undefined
function handleCreate() {
openCreateInstanceModal()
if (selectedSegment !== 'deployments' || appInstanceId)
router.push('/deployments')
setCreateModalOpen(true)
}
function handleLoadMore() {
if (listQuery.hasNextPage && !listQuery.isFetchingNextPage)
void listQuery.fetchNextPage()
}
return (
<Nav
isApp={false}
icon={<span aria-hidden className="i-ri-rocket-line size-4" />}
activeIcon={<span aria-hidden className="i-ri-rocket-fill size-4" />}
text={t('menus.deployments', { ns: 'common' })}
activeSegment="deployments"
link="/deployments"
curNav={curNav}
navigationItems={navigationItems}
createText={t('deployments:createModal.title')}
onCreate={handleCreate}
/>
<>
<Nav
isApp={false}
icon={<span aria-hidden className="i-ri-rocket-line size-4" />}
activeIcon={<span aria-hidden className="i-ri-rocket-fill size-4" />}
text={t('menus.deployments', { ns: 'common' })}
activeSegment="deployments"
link="/deployments"
curNav={curNav}
navigationItems={navigationItems}
createText={t('deployments:createModal.title')}
onCreate={handleCreate}
onLoadMore={handleLoadMore}
isLoadingMore={listQuery.isFetchingNextPage}
/>
<CreateInstanceModal open={createModalOpen} onOpenChange={setCreateModalOpen} />
</>
)
}

View File

@ -1,16 +1,26 @@
import type { RuntimeInstanceRow } from '@dify/contracts/enterprise/types.gen'
type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed'
type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed' | 'unknown'
export function isUndeployedDeploymentRow(row?: RuntimeInstanceRow) {
return (row?.status?.toLowerCase() ?? '').includes('undeployed') || (!row?.id && !row?.currentRelease && !row?.detail)
}
export function deploymentStatus(row: RuntimeInstanceRow): DeploymentUiStatus {
const runtimeStatus = row.status?.toLowerCase() ?? ''
export function deploymentStatus(row?: Pick<RuntimeInstanceRow, 'status'>): DeploymentUiStatus {
const runtimeStatus = row?.status?.toLowerCase() ?? ''
if (!runtimeStatus || runtimeStatus.includes('undeployed'))
return 'unknown'
if (runtimeStatus.includes('deploying') || runtimeStatus.includes('pending'))
return 'deploying'
if (runtimeStatus.includes('fail') || runtimeStatus.includes('error'))
return 'deploy_failed'
return 'ready'
if (runtimeStatus.includes('ready')
|| runtimeStatus.includes('running')
|| runtimeStatus.includes('active')
|| runtimeStatus.includes('success')
|| runtimeStatus.includes('succeed')
|| runtimeStatus.includes('deployed')) {
return 'ready'
}
return 'unknown'
}

View File

@ -11,8 +11,6 @@ export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
export const createInstanceModalOpenAtom = atom(false)
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
set(deployDrawerEnvironmentIdAtom, params.environmentId)
@ -25,10 +23,3 @@ export const closeDeployDrawerAtom = atom(null, (_get, set) => {
set(deployDrawerEnvironmentIdAtom, undefined)
set(deployDrawerReleaseIdAtom, undefined)
})
export const openCreateInstanceModalAtom = atom(null, (_get, set) => {
set(createInstanceModalOpenAtom, true)
})
export const closeCreateInstanceModalAtom = atom(null, (_get, set) => {
set(createInstanceModalOpenAtom, false)
})

View File

@ -91,6 +91,8 @@
"card.notDeployed": "Not deployed",
"card.ready": "{{count}} ready",
"card.tooltip.notDeployed": "This instance has not been deployed to any environment yet.",
"common.loadFailed": "Failed to load. Try again later.",
"common.loading": "Loading...",
"createModal.appPickerPlaceholder": "Select a source app",
"createModal.appSearchEmpty": "No matching apps",
"createModal.appSearchPlaceholder": "Search apps…",
@ -100,6 +102,7 @@
"createModal.description": "Pick a source app from Studio and create a deployable instance.",
"createModal.descriptionLabel": "Description",
"createModal.descriptionPlaceholder": "Describe what this instance is used for",
"createModal.loadMoreApps": "Load more apps",
"createModal.loadingApps": "Loading apps…",
"createModal.nameLabel": "Instance name",
"createModal.namePlaceholder": "Instance name",
@ -211,6 +214,7 @@
"filter.searchPlaceholder": "Search instances",
"health.degraded": "Degraded",
"health.ready": "Ready",
"list.empty": "No app instances found.",
"mode.isolated": "Isolated",
"mode.shared": "Shared",
"newInstance.comingSoon": "Coming soon",
@ -274,6 +278,7 @@
"status.deploying": "Deploying",
"status.notDeployed": "Not deployed",
"status.ready": "Ready",
"status.unknown": "Unknown",
"subtitle": "Deploy and manage your apps across environments.",
"tabs.deploy.description": "Environments this instance is deployed to and their current releases.",
"tabs.deploy.name": "Deploy",

View File

@ -91,6 +91,8 @@
"card.notDeployed": "未部署",
"card.ready": "{{count}} 个就绪",
"card.tooltip.notDeployed": "该实例尚未部署到任何环境。",
"common.loadFailed": "加载失败,请稍后再试。",
"common.loading": "加载中...",
"createModal.appPickerPlaceholder": "选择源应用",
"createModal.appSearchEmpty": "没有匹配的应用",
"createModal.appSearchPlaceholder": "搜索应用…",
@ -100,6 +102,7 @@
"createModal.description": "从 Studio 选择一个源应用并创建可部署的实例。",
"createModal.descriptionLabel": "描述",
"createModal.descriptionPlaceholder": "描述该实例的用途",
"createModal.loadMoreApps": "加载更多应用",
"createModal.loadingApps": "正在加载应用…",
"createModal.nameLabel": "实例名称",
"createModal.namePlaceholder": "实例名称",
@ -211,6 +214,7 @@
"filter.searchPlaceholder": "搜索实例",
"health.degraded": "降级",
"health.ready": "就绪",
"list.empty": "未找到应用实例。",
"mode.isolated": "独立",
"mode.shared": "共享",
"newInstance.comingSoon": "即将支持",
@ -274,6 +278,7 @@
"status.deploying": "部署中",
"status.notDeployed": "未部署",
"status.ready": "就绪",
"status.unknown": "未知",
"subtitle": "在不同环境中部署和管理你的应用。",
"tabs.deploy.description": "该实例已部署的环境及其当前发布版本。",
"tabs.deploy.name": "部署",