diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 0e897587cc..546f97611d 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -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 diff --git a/web/app/(commonLayout)/deployments/page.tsx b/web/app/(commonLayout)/deployments/page.tsx index 1d085df265..753f0a1837 100644 --- a/web/app/(commonLayout)/deployments/page.tsx +++ b/web/app/(commonLayout)/deployments/page.tsx @@ -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 + return } diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 699d2a4348..473fb45bda 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -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' diff --git a/web/app/components/header/__tests__/index.spec.tsx b/web/app/components/header/__tests__/index.spec.tsx index 0e9ef6493d..699e3c20e5 100644 --- a/web/app/components/header/__tests__/index.spec.tsx +++ b/web/app/components/header/__tests__/index.spec.tsx @@ -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 () =>
diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx index 4e82775a17..2181950577 100644 --- a/web/app/components/header/index.tsx +++ b/web/app/components/header/index.tsx @@ -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 = () => ( -

- - {isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'} - {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? ( - logo - ) - : } - -

- ) + function renderLogo() { + return ( +

+ + {isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'} + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo + ? ( + logo + ) + : } + +

+ ) + } if (isMobile) { return ( @@ -74,14 +76,12 @@ const Header = () => { {enableBilling ? : }
-
-
- -
+
+
-
+
{!isCurrentWorkspaceDatasetOperator && } {!isCurrentWorkspaceDatasetOperator && } {(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && } @@ -102,21 +102,18 @@ const Header = () => { {enableBilling ? : }
-
+
{!isCurrentWorkspaceDatasetOperator && } {!isCurrentWorkspaceDatasetOperator && } {(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && } {!isCurrentWorkspaceDatasetOperator && } {isCurrentWorkspaceEditor && }
-
+
-
- -
+
) } -export default Header diff --git a/web/features/deployments/components/create-instance-modal.tsx b/web/features/deployments/components/create-instance-modal.tsx index bf8f3ace25..b99e4896a6 100644 --- a/web/features/deployments/components/create-instance-modal.tsx +++ b/web/features/deployments/components/create-instance-modal.tsx @@ -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 ( + + {app && ( + + )} + + {app?.name ?? t('createModal.appPickerPlaceholder')} + + + ) +} + +function SourceAppOption({ app }: { + app: App +}) { + const { t } = useTranslation('deployments') + const modeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode }) + + return ( + + + + + {app.name} + + ( + {app.id.slice(0, 8)} + ) + + + + {modeLabel} + + ) +} + +function SourceAppPickerSkeleton() { + return ( +
+ {SOURCE_APP_PICKER_SKELETON_KEYS.map(key => ( + + + + + ))} +
+ ) +} 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 ( - } - isShow={isShow} - onShowChange={setIsShow} - onSelect={onChange} - apps={apps} - isLoading={isLoading || isFetchingNextPage} - hasMore={hasNextPage ?? true} - onLoadMore={() => { - void fetchNextPage() + + 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} + > + + + + +
+
+ + +
+
+ {(isLoading || isFetchingNextPage) && apps.length === 0 && } + + {(app: App) => ( + + )} + + {!(isLoading || isFetchingNextPage) && ( + + {t('createModal.appSearchEmpty')} + + )} + {hasNextPage && ( +
+ +
+ )} +
+
+
+ ) } -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() @@ -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() { -
@@ -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" />
-
) } + +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 + } + + if (releaseHistoryQuery.isError || environmentOptionsQuery.isError || runtimeInstancesQuery.isError) { + return ( +
+ {t('common.loadFailed')} +
+ ) + } + + 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 ( + + ) +} diff --git a/web/features/deployments/components/status-badge.tsx b/web/features/deployments/components/status-badge.tsx index 77b33c54eb..ae2549f4af 100644 --- a/web/features/deployments/components/status-badge.tsx +++ b/web/features/deployments/components/status-badge.tsx @@ -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 = { 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 const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap' diff --git a/web/features/deployments/data.ts b/web/features/deployments/data.ts index 0ba9317308..a474cddec0 100644 --- a/web/features/deployments/data.ts +++ b/web/features/deployments/data.ts @@ -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 +} diff --git a/web/features/deployments/detail/access-tab/channels-section.tsx b/web/features/deployments/detail/access-tab/channels-section.tsx deleted file mode 100644 index feef4b15f2..0000000000 --- a/web/features/deployments/detail/access-tab/channels-section.tsx +++ /dev/null @@ -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 ( - { - 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 ( -
- )} - > - {runEnabled - ? ( -
-
-
-
-
- {t('access.runAccess.webapp')} -
- - {t('access.channels.followPermission')} - -
-
- {t('access.runAccess.webappDesc')} -
- {webappRows.length > 0 - ? ( -
- {webappRows.map((row) => { - const endpointUrl = webappUrl(row.url) - - return ( - - ) - })} -
- ) - : ( -
- {t('access.runAccess.webappEmpty')} -
- )} -
-
-
-
- {t('access.cli.title')} -
- - {t('access.channels.followPermission')} - -
-
- {t('access.cli.description')} -
- {cliDomain - ? ( - - ) - : ( -
- {t('access.cli.empty')} -
- )} -
-
-
- ) - : ( -
- {t('access.channels.disabled')} -
- )} -
- ) -} diff --git a/web/features/deployments/detail/access-tab/developer-api-section.tsx b/web/features/deployments/detail/access-tab/developer-api-section.tsx deleted file mode 100644 index 2c0e8e4471..0000000000 --- a/web/features/deployments/detail/access-tab/developer-api-section.tsx +++ /dev/null @@ -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 ( - { - toggleDeveloperAPI.mutate({ - params: { appInstanceId }, - body: { enabled }, - }) - }} - /> - ) -} - -function CreatedApiTokenCard({ token, onDismiss }: { - token: string - onDismiss: () => void -}) { - const { t } = useTranslation('deployments') - - return ( -
-
-
- - {t('access.api.newTokenTitle')} - - - {t('access.api.newTokenDescription')} - -
- -
- -
- ) -} - -export function DeveloperApiSection({ - appInstanceId, -}: DeveloperApiSectionProps) { - const { t } = useTranslation('deployments') - const [createdApiToken, setCreatedApiToken] = useState() - 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 ( -
- )} - > - {apiEnabled - ? ( -
- {apiUrl && ( - - )} -
-
- - {t('access.api.backendTitle')} - - - {t('access.api.keyList')} - -
- setCreatedApiToken({ appInstanceId, token })} - /> -
- {visibleCreatedApiToken && ( - setCreatedApiToken(undefined)} - /> - )} - {apiKeys.length === 0 - ? ( -
- {environments.length === 0 - ? t('access.api.empty') - : t('access.api.noKeys')} -
- ) - : ( - - )} -
- ) - : ( -
- {t('access.api.disabled')} -
- )} -
- ) -} diff --git a/web/features/deployments/detail/access-tab/permissions-section.tsx b/web/features/deployments/detail/access-tab/permissions-section.tsx deleted file mode 100644 index b968ae9b8a..0000000000 --- a/web/features/deployments/detail/access-tab/permissions-section.tsx +++ /dev/null @@ -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 -} { - 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 ( -
- {permissionRows.length === 0 - ? ( -
- {t('access.runAccess.noEnvs')} -
- ) - : ( -
- {permissionRows.map(row => ( - - ))} -
- )} -
- ) -} diff --git a/web/features/deployments/detail/common.tsx b/web/features/deployments/detail/common.tsx index 37b9a2fa26..b738f4650f 100644 --- a/web/features/deployments/detail/common.tsx +++ b/web/features/deployments/detail/common.tsx @@ -9,6 +9,16 @@ type SectionProps = { children: ReactNode } +export function SectionState({ children }: { + children: ReactNode +}) { + return ( +
+ {children} +
+ ) +} + export function Section({ title, description, action, children }: SectionProps) { return (
diff --git a/web/features/deployments/detail/deploy-tab.tsx b/web/features/deployments/detail/deploy-tab.tsx index 9a837997d2..d3ac44304d 100644 --- a/web/features/deployments/detail/deploy-tab.tsx +++ b/web/features/deployments/detail/deploy-tab.tsx @@ -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 +} + +const DEPLOYMENT_TABLE_HEADER_SKELETON_KEYS = ['environment', 'release', 'updated-at', 'actions'] +const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging', 'development', 'preview'] + +function DeploymentEnvironmentListSkeleton() { + return ( +
+
+ {DEPLOYMENT_TABLE_HEADER_SKELETON_KEYS.map(key => ( + + ))} +
+ {DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => ( +
+
+
+
+ + +
+
+ + +
+
+ + + + +
+ +
+
+ +
+
+
+ ))} +
+ ) +} + 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 (
@@ -115,18 +166,24 @@ export function DeployTab({ appInstanceId }: { )
- + {isLoading + ? + : !hasError && }
- {rows.length === 0 - ? ( -
- {t('deployTab.empty')} -
- ) - : ( - - )} + {isLoading + ? + : hasError + ? {t('common.loadFailed')} + : rows.length === 0 + ? ( +
+ {t('deployTab.empty')} +
+ ) + : ( + + )} ) } diff --git a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx b/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx index 76dac2f595..c5b69e71db 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx @@ -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')} {!isUndeployed && ( diff --git a/web/features/deployments/detail/deploy-tab/deployment-panel.tsx b/web/features/deployments/detail/deploy-tab/deployment-panel.tsx index 68a7cfe1dc..1e1a3fa4cc 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-panel.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-panel.tsx @@ -18,8 +18,8 @@ function InfoBlock({ title, children }: { children: ReactNode }) { return ( -
-
{title}
+
+
{title}
{children}
) diff --git a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx b/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx index 0c654ca98c..2a97cec0df 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx @@ -42,6 +42,15 @@ export function DeploymentStatusSummary({ row }: { ) } + if (status === 'unknown') { + return ( + + + {t('status.unknown')} + + ) + } + return ( diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx index fd0ec0d81e..a54abcc541 100644 --- a/web/features/deployments/detail/deployment-sidebar.tsx +++ b/web/features/deployments/detail/deployment-sidebar.tsx @@ -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 ( +
+
+ {isLoading + ? ( + <> + + {expand && ( + + + + + )} + + ) + : isUnavailable + ? ( + <> +
+ +
+ {expand && ( +
+
+ {t('detail.notFound')} +
+
+ {appInstanceId} +
+
+ )} + + ) + : ( + <> +
+ +
+ {expand && ( +
+
+
+ {instanceName} +
+
+
+ {appModeLabel} +
+ {app.description && ( +
+ {app.description} +
+ )} +
+ )} + + )} +
+
+ ) +} + +export function DeploymentSidebar({ appInstanceId }: DeploymentSidebarProps) { + const { t } = useTranslation('deployments') const sidebarRef = useRef(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', )} > -
-
-
- -
- {expand && ( -
-
-
- {instanceName} -
-
-
- {appModeLabel} -
- {app.description && ( -
- {app.description} -
- )} -
- )} -
-
+
- -
- ) - } - - if (!resolvedAppInstanceId || !app) { - return ( -
-
{t('detail.notFound')}
- -
- ) - } - return ( <>
- - +
@@ -75,7 +40,6 @@ export function InstanceDetail({ appInstanceId, children }: {
- ) diff --git a/web/features/deployments/detail/overview-tab.tsx b/web/features/deployments/detail/overview-tab.tsx index f7167d7181..62704fbd22 100644 --- a/web/features/deployments/detail/overview-tab.tsx +++ b/web/features/deployments/detail/overview-tab.tsx @@ -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 ( +
+ {rows.map(row => ( +
+ {row.label} +
+ +
+
+ ))} +
+ ) +} + +function StatusRowsSkeleton() { + return ( +
+ {STATUS_ROW_SKELETON_KEYS.map(key => ( +
+
+ + +
+ +
+ ))} +
+ ) +} + +function AccessRowsSkeleton({ rows }: { + rows: Array<{ + key: string + label: string + hintClassName: string + showMeta?: boolean + }> +}) { + return ( +
+ {rows.map(row => ( +
+
+ {row.label} + + {row.showMeta && } +
+ +
+ ))} +
+ ) +} 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 ( +
+ +
+ ) + } + + if (overviewQuery.isError) { + return ( +
+ {t('common.loadFailed')} +
+ ) + } + + if (!overviewApp?.id) { + return ( +
+ {t('detail.notFound')} +
+ ) + } 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 = ( + + ) - if (!overviewApp?.id) - return null + if (overviewQuery.isLoading || releaseHistoryQuery.isLoading) { + return ( +
+ +
+ ) + } + + if (overviewQuery.isError || releaseHistoryQuery.isError) { + return ( +
+ {t('common.loadFailed')} +
+ ) + } + + if (!overviewApp?.id) { + return ( +
+ {t('detail.notFound')} +
+ ) + } return (
}> - {t('overview.viewDeployments')} - - - )} + action={action} > {deployments.length === 0 ? ( @@ -178,7 +284,7 @@ function DeploymentStatusSection({ appInstanceId }: { : (
{deployments.map((row) => { - const status = overviewDeploymentStatus(row.status) + const status = deploymentStatus(row) return (
@@ -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 = ( + + ) + + if (overviewQuery.isLoading || accessConfigQuery.isLoading) { + return ( +
+ +
+ ) + } + + if (overviewQuery.isError || accessConfigQuery.isError) { + return ( +
+ {t('common.loadFailed')} +
+ ) + } + + if (!overview?.instance?.id) { + return ( +
+ {t('detail.notFound')} +
+ ) + } return (
}> - {t('overview.configureAccess')} - - - )} + action={action} >
+ {SETTINGS_FORM_SKELETON_FIELDS.map(field => ( +
+ + +
+ ))} + + + + +
+ ) +} + +function DeleteInstanceSkeleton() { + return ( +
+ + + + + + +
+ ) } 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({