mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 15:58:19 +08:00
tweaks
This commit is contained in:
parent
6d0d0763b1
commit
75bfb58cd9
@ -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
|
||||
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)))}
|
||||
/>
|
||||
))}
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "部署",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user