From 6d0d0763b1a2df6c6bb2b0109398ffe833551803 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 11 May 2026 21:16:28 +0800 Subject: [PATCH] tweaks --- .../skills/how-to-write-component/SKILL.md | 7 +- .../{utils.spec.ts => release-action.spec.ts} | 2 +- web/features/deployments/app-mode.ts | 7 + .../deployments/components/deploy-drawer.tsx | 8 +- .../components/deploy-drawer/form.tsx | 25 +- .../components/deploy-drawer/select.tsx | 8 +- .../deployments/components/status-badge.tsx | 5 +- .../detail/access-tab/api-keys.tsx | 2 +- .../detail/access-tab/channels-section.tsx | 3 +- .../detail/access-tab/permissions.tsx | 26 +- .../deployments/detail/deploy-tab.tsx | 23 +- .../deployment-environment-list.tsx | 16 +- .../detail/deploy-tab/deployment-panel.tsx | 15 +- .../deploy-tab/deployment-status-summary.tsx | 9 +- .../deployments/detail/deployment-sidebar.tsx | 2 +- .../deployments/detail/overview-tab.tsx | 8 +- .../deployments/detail/settings-tab.tsx | 4 +- .../versions-tab/deploy-release-menu.tsx | 26 +- .../versions-tab/release-deployments.ts | 10 +- .../versions-tab/release-history-table.tsx | 6 +- web/features/deployments/environment.ts | 34 +++ .../deployments/list/instance-card.tsx | 2 +- web/features/deployments/nav/index.tsx | 2 +- web/features/deployments/release-action.ts | 69 ++++++ web/features/deployments/release.ts | 15 ++ web/features/deployments/runtime-bindings.ts | 17 ++ web/features/deployments/runtime-status.ts | 16 ++ web/features/deployments/types.ts | 12 - web/features/deployments/utils.ts | 233 ------------------ web/features/deployments/webapp-url.ts | 26 ++ 30 files changed, 295 insertions(+), 343 deletions(-) rename web/features/deployments/__tests__/{utils.spec.ts => release-action.spec.ts} (98%) create mode 100644 web/features/deployments/app-mode.ts create mode 100644 web/features/deployments/environment.ts create mode 100644 web/features/deployments/release-action.ts create mode 100644 web/features/deployments/release.ts create mode 100644 web/features/deployments/runtime-bindings.ts create mode 100644 web/features/deployments/runtime-status.ts delete mode 100644 web/features/deployments/types.ts delete mode 100644 web/features/deployments/utils.ts create mode 100644 web/features/deployments/webapp-url.ts diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 62d341f187..0e897587cc 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -1,6 +1,6 @@ --- name: how-to-write-component -description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. +description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. --- # How To Write A Component @@ -12,6 +12,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. - Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them. - Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature. +- Prefer local code and purpose-named helpers over catch-all utility modules; inline cheap derived values when that is clearer. - Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`. ## Ownership @@ -30,9 +31,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs. - Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files. - Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer. -- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them. +- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them. - Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary. -- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks. +- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states. ## Queries And Mutations diff --git a/web/features/deployments/__tests__/utils.spec.ts b/web/features/deployments/__tests__/release-action.spec.ts similarity index 98% rename from web/features/deployments/__tests__/utils.spec.ts rename to web/features/deployments/__tests__/release-action.spec.ts index 8076ec91e5..6fcef705f7 100644 --- a/web/features/deployments/__tests__/utils.spec.ts +++ b/web/features/deployments/__tests__/release-action.spec.ts @@ -1,6 +1,6 @@ import type { ConsoleRelease, ReleaseRow } from '@dify/contracts/enterprise/types.gen' import { describe, expect, it } from 'vitest' -import { releaseDeploymentAction } from '../utils' +import { releaseDeploymentAction } from '../release-action' function release(overrides: ReleaseRow): ReleaseRow { return overrides diff --git a/web/features/deployments/app-mode.ts b/web/features/deployments/app-mode.ts new file mode 100644 index 0000000000..3345c64956 --- /dev/null +++ b/web/features/deployments/app-mode.ts @@ -0,0 +1,7 @@ +import { AppModeEnum } from '@/types/app' + +const appModeValues = new Set(Object.values(AppModeEnum)) + +export function toAppMode(mode?: string): AppModeEnum { + return appModeValues.has(mode ?? '') ? (mode as AppModeEnum) : AppModeEnum.WORKFLOW +} diff --git a/web/features/deployments/components/deploy-drawer.tsx b/web/features/deployments/components/deploy-drawer.tsx index 934a072206..7ac267f199 100644 --- a/web/features/deployments/components/deploy-drawer.tsx +++ b/web/features/deployments/components/deploy-drawer.tsx @@ -13,7 +13,6 @@ import { deployDrawerOpenAtom, deployDrawerReleaseIdAtom, } from '../store' -import { environmentOptionsFromOptionsReply } from '../utils' import { DeployForm } from './deploy-drawer/form' export function DeployDrawer() { @@ -39,7 +38,12 @@ export function DeployDrawer() { enabled: open, })) - const environments = environmentOptionsFromOptionsReply(environmentOptionsReply) + 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'}` diff --git a/web/features/deployments/components/deploy-drawer/form.tsx b/web/features/deployments/components/deploy-drawer/form.tsx index b9e962da94..b727daedb6 100644 --- a/web/features/deployments/components/deploy-drawer/form.tsx +++ b/web/features/deployments/components/deploy-drawer/form.tsx @@ -1,7 +1,6 @@ 'use client' -import type { DeploymentBindingOptionSlot, DeploymentRuntimeBinding, ReleaseRow } from '@dify/contracts/enterprise/types.gen' -import type { EnvironmentOption } from '@/features/deployments/types' +import type { DeploymentBindingOptionSlot, DeploymentEnvironmentOption, DeploymentRuntimeBinding, ReleaseRow } 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' @@ -10,17 +9,11 @@ import { useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' +import { environmentId, environmentMode, environmentName } from '../../environment' +import { releaseCommit, releaseLabel } from '../../release' +import { releaseDeploymentAction } from '../../release-action' +import { isUndeployedDeploymentRow } from '../../runtime-status' import { closeDeployDrawerAtom } from '../../store' -import { - activeRelease, - deployedRows, - environmentId, - environmentMode, - environmentName, - releaseCommit, - releaseDeploymentAction, - releaseLabel, -} from '../../utils' import { DeploymentSelect, EnvironmentRow, @@ -36,6 +29,10 @@ type DeployFormProps = { presetReleaseId?: string } +type EnvironmentOption = DeploymentEnvironmentOption & { + disabled?: boolean +} + type BindingSelections = Record type BindingSelectOption = { @@ -235,11 +232,11 @@ 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 = deployedRows(environmentDeployments?.data) + const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? [] const selectedDeploymentRow = deploymentRows.find(row => environmentId(row.environment) === selectedEnvironmentId) const action = releaseDeploymentAction({ targetRelease, - currentRelease: activeRelease(selectedDeploymentRow), + currentRelease: selectedDeploymentRow?.currentRelease, releaseRows: releases, isExistingRelease, }) diff --git a/web/features/deployments/components/deploy-drawer/select.tsx b/web/features/deployments/components/deploy-drawer/select.tsx index b4c0510cfc..63fadf8bc9 100644 --- a/web/features/deployments/components/deploy-drawer/select.tsx +++ b/web/features/deployments/components/deploy-drawer/select.tsx @@ -1,12 +1,16 @@ 'use client' -import type { EnvironmentOption } from '@/features/deployments/types' +import type { DeploymentEnvironmentOption } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' -import { environmentHealth, environmentMode, environmentName } from '../../utils' +import { environmentHealth, environmentMode, environmentName } from '../../environment' import { HealthBadge, ModeBadge } from '../status-badge' +type EnvironmentOption = DeploymentEnvironmentOption & { + disabled?: boolean +} + export function Field({ label, hint, children }: { label: string hint?: string diff --git a/web/features/deployments/components/status-badge.tsx b/web/features/deployments/components/status-badge.tsx index 0aa91c29e2..77b33c54eb 100644 --- a/web/features/deployments/components/status-badge.tsx +++ b/web/features/deployments/components/status-badge.tsx @@ -1,8 +1,11 @@ 'use client' -import type { DeployStatus, EnvironmentHealth, EnvironmentMode } from '../types' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' +type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' +type EnvironmentMode = 'shared' | 'isolated' +type EnvironmentHealth = 'ready' | 'degraded' + 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', diff --git a/web/features/deployments/detail/access-tab/api-keys.tsx b/web/features/deployments/detail/access-tab/api-keys.tsx index ccec746906..c8f03652ac 100644 --- a/web/features/deployments/detail/access-tab/api-keys.tsx +++ b/web/features/deployments/detail/access-tab/api-keys.tsx @@ -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 '../../utils' +import { environmentName } from '../../environment' function ApiKeyRow({ appInstanceId, apiKey }: { appInstanceId: string diff --git a/web/features/deployments/detail/access-tab/channels-section.tsx b/web/features/deployments/detail/access-tab/channels-section.tsx index e3b8bd3c90..feef4b15f2 100644 --- a/web/features/deployments/detail/access-tab/channels-section.tsx +++ b/web/features/deployments/detail/access-tab/channels-section.tsx @@ -4,7 +4,8 @@ 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, webappUrl } from '../../utils' +import { environmentName } from '../../environment' +import { webappUrl } from '../../webapp-url' import { Section } from '../common' import { CopyPill, EndpointRow } from './common' import { getUrlOrigin } from './url' diff --git a/web/features/deployments/detail/access-tab/permissions.tsx b/web/features/deployments/detail/access-tab/permissions.tsx index b0e73a3bce..db9f675889 100644 --- a/web/features/deployments/detail/access-tab/permissions.tsx +++ b/web/features/deployments/detail/access-tab/permissions.tsx @@ -7,7 +7,6 @@ import type { ConsoleEnvironment, EnvironmentAccessRow, } from '@dify/contracts/enterprise/types.gen' -import type { AccessPermissionKind } from '../../types' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -22,11 +21,26 @@ import { useDebounce } from 'ahooks' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' -import { - accessModeToPermissionKey, - environmentName, - permissionKeyToAccessMode, -} from '../../utils' +import { environmentName } from '../../environment' + +type AccessPermissionKind = 'organization' | 'specific' | 'anyone' + +function accessModeToPermissionKey(mode?: string): AccessPermissionKind { + const normalized = mode?.toLowerCase() ?? '' + if (normalized === 'private') + return 'specific' + if (normalized === 'public') + return 'anyone' + return 'organization' +} + +function permissionKeyToAccessMode(key: AccessPermissionKind) { + if (key === 'organization') + return 'private_all' + if (key === 'specific') + return 'private' + return 'public' +} const permissionIcon: Record = { organization: 'i-ri-team-line', diff --git a/web/features/deployments/detail/deploy-tab.tsx b/web/features/deployments/detail/deploy-tab.tsx index c7ddeb30c3..9a837997d2 100644 --- a/web/features/deployments/detail/deploy-tab.tsx +++ b/web/features/deployments/detail/deploy-tab.tsx @@ -1,5 +1,5 @@ 'use client' -import type { EnvironmentOption } from '../types' +import type { DeploymentEnvironmentOption } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -12,15 +12,15 @@ import { useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' +import { environmentId, environmentName } from '../environment' +import { isUndeployedDeploymentRow } from '../runtime-status' import { openDeployDrawerAtom } from '../store' -import { - deployedRows, - environmentId, - environmentName, - environmentOptionsFromOptionsReply, -} from '../utils' import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list' +type EnvironmentOption = DeploymentEnvironmentOption & { + disabled?: boolean +} + function NewDeploymentMenu({ appInstanceId, availableEnvs }: { appInstanceId: string availableEnvs: EnvironmentOption[] @@ -91,9 +91,14 @@ export function DeployTab({ appInstanceId }: { }, })) const { data: environmentOptionsReply } = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions()) - const environmentOptions = environmentOptionsFromOptionsReply(environmentOptionsReply) + const environmentOptions = environmentOptionsReply?.environments + ?.filter(environment => environment.id) + .map(environment => ({ + ...environment, + disabled: environment.deployable === false, + })) ?? [] const rows = environmentDeployments?.data?.filter(row => row.environment?.id) ?? [] - const deployedRuntimeRows = deployedRows(environmentDeployments?.data) + const deployedRuntimeRows = rows.filter(row => !isUndeployedDeploymentRow(row)) const deployedEnvIds = new Set(deployedRuntimeRows.map(row => environmentId(row.environment))) const availableEnvs = environmentOptions.filter(env => env.id && !deployedEnvIds.has(env.id)) 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 14a495189f..76dac2f595 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx @@ -15,19 +15,15 @@ import { useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' -import { openDeployDrawerAtom } from '../../store' import { - activeRelease, - deploymentId, - deploymentStatus, environmentBackend, environmentId, environmentMode, environmentName, - isUndeployedDeploymentRow, - releaseCommit, - releaseLabel, -} from '../../utils' +} from '../../environment' +import { releaseCommit, releaseLabel } from '../../release' +import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status' +import { openDeployDrawerAtom } from '../../store' import { DeploymentPanel } from './deployment-panel' import { DeploymentStatusSummary } from './deployment-status-summary' @@ -47,7 +43,7 @@ function DeploymentRowActions({ appInstanceId, envId, row }: { const status = deploymentStatus(row) function handleRuntimeAction() { - const runtimeInstanceId = deploymentId(row) + const runtimeInstanceId = row.id ?? '' setMenuOpen(false) if (status === 'deploying') { @@ -126,7 +122,7 @@ function DeploymentEnvironmentRow({ appInstanceId, row, isExpanded, onToggle }: const { t } = useTranslation('deployments') const envId = environmentId(row.environment) const isUndeployed = isUndeployedDeploymentRow(row) - const release = activeRelease(row) + const release = row.currentRelease const chevron = !isUndeployed && (
- + 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 b411829e00..0c654ca98c 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx @@ -2,12 +2,11 @@ import type { RuntimeInstanceRow } from '@dify/contracts/enterprise/types.gen' import { useTranslation } from 'react-i18next' +import { releaseLabel } from '../../release' import { - activeRelease, deploymentStatus, isUndeployedDeploymentRow, - releaseLabel, -} from '../../utils' +} from '../../runtime-status' export function DeploymentStatusSummary({ row }: { row: RuntimeInstanceRow @@ -28,13 +27,13 @@ export function DeploymentStatusSummary({ row }: { return ( - {t('deployTab.status.deployingRelease', { release: releaseLabel(activeRelease(row)) })} + {t('deployTab.status.deployingRelease', { release: releaseLabel(row.currentRelease) })} ) } if (status === 'deploy_failed') { - const hasRunningRelease = !!activeRelease(row)?.id + const hasRunningRelease = !!row.currentRelease?.id return ( diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx index 0be8008230..fd0ec0d81e 100644 --- a/web/features/deployments/detail/deployment-sidebar.tsx +++ b/web/features/deployments/detail/deployment-sidebar.tsx @@ -15,7 +15,7 @@ import AppIcon from '@/app/components/base/app-icon' import Divider from '@/app/components/base/divider' import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { toAppMode } from '../utils' +import { toAppMode } from '../app-mode' type TabDef = { key: InstanceDetailTabKey diff --git a/web/features/deployments/detail/overview-tab.tsx b/web/features/deployments/detail/overview-tab.tsx index 445dbabdae..f7167d7181 100644 --- a/web/features/deployments/detail/overview-tab.tsx +++ b/web/features/deployments/detail/overview-tab.tsx @@ -8,14 +8,12 @@ import { useTranslation } from 'react-i18next' import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels' 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 { openDeployDrawerAtom } from '../store' -import { - releaseLabel, - toAppMode, - webappUrl, -} from '../utils' +import { webappUrl } from '../webapp-url' import { Section } from './common' function InfoRow({ label, value, mono }: { diff --git a/web/features/deployments/detail/settings-tab.tsx b/web/features/deployments/detail/settings-tab.tsx index f51ba46fd9..779ae69b35 100644 --- a/web/features/deployments/detail/settings-tab.tsx +++ b/web/features/deployments/detail/settings-tab.tsx @@ -16,7 +16,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' -import { deployedRows } from '../utils' +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' @@ -264,7 +264,7 @@ function DeleteInstanceControlSection({ appInstanceId }: { if (!app?.id) return null - const hasDeployments = deployedRows(environmentDeployments?.data).length > 0 + const hasDeployments = environmentDeployments?.data?.some(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? false return ( environment.id) + .map(environment => ({ + ...environment, + disabled: environment.deployable === false, + })) ?? [] const environments = environmentOptions.filter(env => env.id) - const deploymentRows = deployedRows(environmentDeployments?.data) + const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? [] const targetRelease = releaseRows.find(release => release.id === releaseId) ?? { id: releaseId } return ( @@ -64,12 +63,13 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: { {environments.map((env) => { const envId = env.id! const row = deploymentRows.find(item => environmentId(item.environment) === envId) - const isCurrent = activeRelease(row)?.id === releaseId + const currentRelease = row?.currentRelease + const isCurrent = currentRelease?.id === releaseId const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false const disabled = Boolean(env.disabled || isCurrent || isEnvironmentDeploying) const action = releaseDeploymentAction({ targetRelease, - currentRelease: activeRelease(row), + currentRelease, releaseRows, isExistingRelease: true, }) diff --git a/web/features/deployments/detail/versions-tab/release-deployments.ts b/web/features/deployments/detail/versions-tab/release-deployments.ts index d6dd5a151b..ea1ed00e5d 100644 --- a/web/features/deployments/detail/versions-tab/release-deployments.ts +++ b/web/features/deployments/detail/versions-tab/release-deployments.ts @@ -1,10 +1,6 @@ import type { DeployedEnvironment, ReleaseRow, RuntimeInstanceRow } from '@dify/contracts/enterprise/types.gen' -import { - activeRelease, - deploymentStatus, - environmentId, - environmentName, -} from '../../utils' +import { environmentId, environmentName } from '../../environment' +import { deploymentStatus } from '../../runtime-status' export type ReleaseDeploymentState = 'active' | 'deploying' | 'failed' @@ -52,7 +48,7 @@ export function getReleaseDeployments(row: ReleaseRow, deploymentRows: RuntimeIn return [] const items: ReleaseDeployment[] = [] - if (activeRelease(deployment)?.id === releaseId) { + if (deployment.currentRelease?.id === releaseId) { items.push({ environmentId: envId, environmentName: environmentName(deployment.environment), diff --git a/web/features/deployments/detail/versions-tab/release-history-table.tsx b/web/features/deployments/detail/versions-tab/release-history-table.tsx index 3a3516f4ac..0a656e022f 100644 --- a/web/features/deployments/detail/versions-tab/release-history-table.tsx +++ b/web/features/deployments/detail/versions-tab/release-history-table.tsx @@ -9,11 +9,11 @@ import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' import { DEPLOYMENT_PAGE_SIZE } from '../../data' import { - deployedRows, formatDate, releaseCommit, releaseLabel, -} from '../../utils' +} from '../../release' +import { isUndeployedDeploymentRow } from '../../runtime-status' import { DeployReleaseMenu } from './deploy-release-menu' import { DeployedToBadge } from './deployed-to-badge' import { getReleaseDeployments } from './release-deployments' @@ -174,7 +174,7 @@ export function ReleaseHistoryTable({ appInstanceId }: { || (releaseRows.length === 0 && overviewQuery.isLoading) || (shouldLoadRuntimeInstances && environmentDeploymentsQuery.isLoading) const sourceAppUnavailable = overviewQuery.data?.instance?.canCreateRelease === false - const deploymentRows = deployedRows(environmentDeploymentsQuery.data?.data) + const deploymentRows = environmentDeploymentsQuery.data?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? [] if (isLoading) { return ( diff --git a/web/features/deployments/environment.ts b/web/features/deployments/environment.ts new file mode 100644 index 0000000000..08f7244340 --- /dev/null +++ b/web/features/deployments/environment.ts @@ -0,0 +1,34 @@ +import type { ConsoleEnvironment, DeploymentEnvironmentOption } from '@dify/contracts/enterprise/types.gen' + +export function environmentId(environment?: ConsoleEnvironment | DeploymentEnvironmentOption) { + return environment?.id ?? '' +} + +export function environmentName(environment?: ConsoleEnvironment | DeploymentEnvironmentOption) { + return environment?.name || environment?.id || '—' +} + +export function environmentMode(environment?: ConsoleEnvironment | DeploymentEnvironmentOption) { + const type = environment?.type?.toLowerCase() ?? '' + return type.includes('isolated') ? 'isolated' : 'shared' +} + +function environmentRuntimeName(environment?: ConsoleEnvironment | DeploymentEnvironmentOption) { + if (!environment) + return '' + if ('backend' in environment && environment.backend) + return environment.backend + if ('runtime' in environment && environment.runtime) + return environment.runtime + return '' +} + +export function environmentBackend(environment?: ConsoleEnvironment | DeploymentEnvironmentOption) { + const runtime = environmentRuntimeName(environment).toLowerCase() + return runtime.includes('host') ? 'host' : 'k8s' +} + +export function environmentHealth(environment?: ConsoleEnvironment | DeploymentEnvironmentOption) { + const status = environment?.status?.toLowerCase() ?? '' + return status.includes('ready') ? 'ready' : 'degraded' +} diff --git a/web/features/deployments/list/instance-card.tsx b/web/features/deployments/list/instance-card.tsx index f403f4a144..945ed1def1 100644 --- a/web/features/deployments/list/instance-card.tsx +++ b/web/features/deployments/list/instance-card.tsx @@ -15,7 +15,7 @@ import { AppTypeIcon } from '@/app/components/app/type-selector' import AppIcon from '@/app/components/base/app-icon' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import Link from '@/next/link' -import { toAppMode } from '../utils' +import { toAppMode } from '../app-mode' const INSTANCE_CARD_MENU_TAB_KEYS = ['deploy', 'versions', 'settings'] satisfies InstanceDetailTabKey[] diff --git a/web/features/deployments/nav/index.tsx b/web/features/deployments/nav/index.tsx index 53c6f99945..1907ac46fa 100644 --- a/web/features/deployments/nav/index.tsx +++ b/web/features/deployments/nav/index.tsx @@ -8,9 +8,9 @@ import { useTranslation } from 'react-i18next' import Nav from '@/app/components/header/nav' import { useParams, useRouter, 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 { toAppMode } from '../utils' function navItemFromListApp(app: AppInstanceCard): NavItem[] { if (!app.id || !app.name) diff --git a/web/features/deployments/release-action.ts b/web/features/deployments/release-action.ts new file mode 100644 index 0000000000..f570c01b5f --- /dev/null +++ b/web/features/deployments/release-action.ts @@ -0,0 +1,69 @@ +import type { ConsoleRelease, ReleaseRow } from '@dify/contracts/enterprise/types.gen' + +export type ReleaseDeploymentAction = 'deploy' | 'deployExistingRelease' | 'promote' | 'rollback' + +function releaseCreatedAt(release?: ConsoleRelease | ReleaseRow) { + const value = release?.createdAt + if (!value) + return undefined + + const time = Date.parse(value) + return Number.isFinite(time) ? time : undefined +} + +function releaseById(releaseRows: ReleaseRow[], releaseId?: string) { + return releaseRows.find(release => release.id === releaseId) +} + +function releaseOrderIndex(releaseRows: ReleaseRow[], releaseId?: string) { + return releaseRows.findIndex(release => release.id === releaseId) +} + +function compareReleaseOrder(targetRelease: ConsoleRelease | ReleaseRow | undefined, currentRelease: ConsoleRelease, releaseRows: ReleaseRow[]) { + if (!targetRelease?.id || !currentRelease.id) + return undefined + if (targetRelease.id === currentRelease.id) + return 0 + + const normalizedTargetRelease = releaseById(releaseRows, targetRelease.id) ?? targetRelease + const normalizedCurrentRelease = releaseById(releaseRows, currentRelease.id) ?? currentRelease + const targetCreatedAt = releaseCreatedAt(normalizedTargetRelease) + const currentCreatedAt = releaseCreatedAt(normalizedCurrentRelease) + + if (targetCreatedAt !== undefined && currentCreatedAt !== undefined && targetCreatedAt !== currentCreatedAt) + return targetCreatedAt > currentCreatedAt ? 1 : -1 + + const targetIndex = releaseOrderIndex(releaseRows, targetRelease.id) + const currentIndex = releaseOrderIndex(releaseRows, currentRelease.id) + if (targetIndex >= 0 && currentIndex >= 0 && targetIndex !== currentIndex) + return targetIndex < currentIndex ? 1 : -1 + + return undefined +} + +export function releaseDeploymentAction({ + targetRelease, + currentRelease, + releaseRows, + isExistingRelease, +}: { + targetRelease?: ConsoleRelease | ReleaseRow + currentRelease?: ConsoleRelease + releaseRows: ReleaseRow[] + isExistingRelease?: boolean +}): ReleaseDeploymentAction { + if (!currentRelease?.id) + return isExistingRelease ? 'deployExistingRelease' : 'deploy' + + const order = compareReleaseOrder(targetRelease, currentRelease, releaseRows) + if (order === -1) + return 'rollback' + if (order === 1) + return 'promote' + + return targetRelease?.id && targetRelease.id !== currentRelease.id + ? 'promote' + : isExistingRelease + ? 'deployExistingRelease' + : 'deploy' +} diff --git a/web/features/deployments/release.ts b/web/features/deployments/release.ts new file mode 100644 index 0000000000..b1323e0ea6 --- /dev/null +++ b/web/features/deployments/release.ts @@ -0,0 +1,15 @@ +import type { ConsoleRelease, ReleaseRow } from '@dify/contracts/enterprise/types.gen' + +export function formatDate(value?: string) { + if (!value) + return '—' + return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16) +} + +export function releaseLabel(release?: ConsoleRelease | ReleaseRow) { + return release?.name || release?.id || '—' +} + +export function releaseCommit(release?: ConsoleRelease | ReleaseRow) { + return release && 'shortCommitId' in release ? release.shortCommitId || '—' : '—' +} diff --git a/web/features/deployments/runtime-bindings.ts b/web/features/deployments/runtime-bindings.ts new file mode 100644 index 0000000000..eddf5ad371 --- /dev/null +++ b/web/features/deployments/runtime-bindings.ts @@ -0,0 +1,17 @@ +import type { ReleaseRuntimeBinding } from '@dify/contracts/enterprise/types.gen' + +export function runtimeBindingSummary(binding?: ReleaseRuntimeBinding) { + return binding?.label || binding?.displayValue || binding?.kind || '—' +} + +export function isRuntimeEnvVarBinding(binding?: ReleaseRuntimeBinding) { + return (binding?.kind?.toLowerCase() ?? '').includes('env') +} + +export function isRuntimeModelBinding(binding?: ReleaseRuntimeBinding) { + return (binding?.kind?.toLowerCase() ?? '').includes('model') +} + +export function isRuntimePluginBinding(binding?: ReleaseRuntimeBinding) { + return !isRuntimeEnvVarBinding(binding) && !isRuntimeModelBinding(binding) +} diff --git a/web/features/deployments/runtime-status.ts b/web/features/deployments/runtime-status.ts new file mode 100644 index 0000000000..784fb26c89 --- /dev/null +++ b/web/features/deployments/runtime-status.ts @@ -0,0 +1,16 @@ +import type { RuntimeInstanceRow } from '@dify/contracts/enterprise/types.gen' + +type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed' + +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() ?? '' + if (runtimeStatus.includes('deploying') || runtimeStatus.includes('pending')) + return 'deploying' + if (runtimeStatus.includes('fail') || runtimeStatus.includes('error')) + return 'deploy_failed' + return 'ready' +} diff --git a/web/features/deployments/types.ts b/web/features/deployments/types.ts deleted file mode 100644 index de02f16436..0000000000 --- a/web/features/deployments/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { DeploymentEnvironmentOption } from '@dify/contracts/enterprise/types.gen' - -export type EnvironmentMode = 'shared' | 'isolated' -export type EnvironmentHealth = 'ready' | 'degraded' - -export type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' - -export type AccessPermissionKind = 'organization' | 'specific' | 'anyone' - -export type EnvironmentOption = DeploymentEnvironmentOption & { - disabled?: boolean -} diff --git a/web/features/deployments/utils.ts b/web/features/deployments/utils.ts deleted file mode 100644 index f31da17be2..0000000000 --- a/web/features/deployments/utils.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { - ConsoleEnvironment, - ConsoleRelease, - ListDeploymentEnvironmentOptionsReply, - ReleaseRow, - ReleaseRuntimeBinding, - RuntimeInstanceRow, -} from '@dify/contracts/enterprise/types.gen' -import type { - AccessPermissionKind, - EnvironmentOption, -} from './types' -import { PUBLIC_API_PREFIX } from '@/config' -import { AppModeEnum } from '@/types/app' - -export type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed' -export type ReleaseDeploymentAction = 'deploy' | 'deployExistingRelease' | 'promote' | 'rollback' - -const appModeValues = new Set(Object.values(AppModeEnum)) - -export function toAppMode(mode?: string): AppModeEnum { - return appModeValues.has(mode ?? '') ? (mode as AppModeEnum) : AppModeEnum.WORKFLOW -} - -export function formatDate(value?: string) { - if (!value) - return '—' - return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16) -} - -export function environmentId(environment?: ConsoleEnvironment | EnvironmentOption) { - return environment?.id ?? '' -} - -export function environmentName(environment?: ConsoleEnvironment | EnvironmentOption) { - return environment?.name || environment?.id || '—' -} - -export function environmentMode(environment?: ConsoleEnvironment | EnvironmentOption) { - const type = environment?.type?.toLowerCase() ?? '' - return type.includes('isolated') ? 'isolated' : 'shared' -} - -function environmentRuntimeName(environment?: ConsoleEnvironment | EnvironmentOption) { - if (!environment) - return '' - if ('backend' in environment && environment.backend) - return environment.backend - if ('runtime' in environment && environment.runtime) - return environment.runtime - return '' -} - -export function environmentBackend(environment?: ConsoleEnvironment | EnvironmentOption) { - const runtime = environmentRuntimeName(environment).toLowerCase() - return runtime.includes('host') ? 'host' : 'k8s' -} - -export function environmentHealth(environment?: ConsoleEnvironment | EnvironmentOption) { - const status = environment?.status?.toLowerCase() ?? '' - return status.includes('ready') ? 'ready' : 'degraded' -} - -export function releaseLabel(release?: ConsoleRelease | ReleaseRow) { - return release?.name || release?.id || '—' -} - -export function releaseCommit(release?: ConsoleRelease | ReleaseRow) { - return release && 'shortCommitId' in release ? release.shortCommitId || '—' : '—' -} - -function releaseCreatedAt(release?: ConsoleRelease | ReleaseRow) { - const value = release?.createdAt - if (!value) - return undefined - - const time = Date.parse(value) - return Number.isFinite(time) ? time : undefined -} - -function releaseById(releaseRows: ReleaseRow[], releaseId?: string) { - return releaseRows.find(release => release.id === releaseId) -} - -function releaseOrderIndex(releaseRows: ReleaseRow[], releaseId?: string) { - return releaseRows.findIndex(release => release.id === releaseId) -} - -function compareReleaseOrder(targetRelease: ConsoleRelease | ReleaseRow | undefined, currentRelease: ConsoleRelease, releaseRows: ReleaseRow[]) { - if (!targetRelease?.id || !currentRelease.id) - return undefined - if (targetRelease.id === currentRelease.id) - return 0 - - const normalizedTargetRelease = releaseById(releaseRows, targetRelease.id) ?? targetRelease - const normalizedCurrentRelease = releaseById(releaseRows, currentRelease.id) ?? currentRelease - const targetCreatedAt = releaseCreatedAt(normalizedTargetRelease) - const currentCreatedAt = releaseCreatedAt(normalizedCurrentRelease) - - if (targetCreatedAt !== undefined && currentCreatedAt !== undefined && targetCreatedAt !== currentCreatedAt) - return targetCreatedAt > currentCreatedAt ? 1 : -1 - - const targetIndex = releaseOrderIndex(releaseRows, targetRelease.id) - const currentIndex = releaseOrderIndex(releaseRows, currentRelease.id) - if (targetIndex >= 0 && currentIndex >= 0 && targetIndex !== currentIndex) - return targetIndex < currentIndex ? 1 : -1 - - return undefined -} - -export function releaseDeploymentAction({ - targetRelease, - currentRelease, - releaseRows, - isExistingRelease, -}: { - targetRelease?: ConsoleRelease | ReleaseRow - currentRelease?: ConsoleRelease - releaseRows: ReleaseRow[] - isExistingRelease?: boolean -}): ReleaseDeploymentAction { - if (!currentRelease?.id) - return isExistingRelease ? 'deployExistingRelease' : 'deploy' - - const order = compareReleaseOrder(targetRelease, currentRelease, releaseRows) - if (order === -1) - return 'rollback' - if (order === 1) - return 'promote' - - return targetRelease?.id && targetRelease.id !== currentRelease.id - ? 'promote' - : isExistingRelease - ? 'deployExistingRelease' - : 'deploy' -} - -export function runtimeBindingSummary(binding?: ReleaseRuntimeBinding) { - return binding?.label || binding?.displayValue || binding?.kind || '—' -} - -export function isRuntimeEnvVarBinding(binding?: ReleaseRuntimeBinding) { - return (binding?.kind?.toLowerCase() ?? '').includes('env') -} - -export function isRuntimeModelBinding(binding?: ReleaseRuntimeBinding) { - return (binding?.kind?.toLowerCase() ?? '').includes('model') -} - -export function isRuntimePluginBinding(binding?: ReleaseRuntimeBinding) { - return !isRuntimeEnvVarBinding(binding) && !isRuntimeModelBinding(binding) -} - -const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:\/\//i - -function withLeadingSlash(path: string) { - return path.startsWith('/') ? path : `/${path}` -} - -function publicWebappOrigin() { - try { - return new URL(PUBLIC_API_PREFIX).origin - } - catch { - return PUBLIC_API_PREFIX.replace(/\/api\/?$/, '').replace(/\/+$/, '') - } -} - -export function webappUrl(url?: string) { - if (!url) - return '' - if (absoluteUrlRegExp.test(url)) - return url - - const origin = publicWebappOrigin() - return `${origin}${withLeadingSlash(url)}` -} - -export function deploymentId(row?: RuntimeInstanceRow) { - return row?.id || '' -} - -export function activeRelease(row?: RuntimeInstanceRow) { - return row?.currentRelease -} - -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() ?? '' - if (runtimeStatus.includes('deploying') || runtimeStatus.includes('pending')) - return 'deploying' - if (runtimeStatus.includes('fail') || runtimeStatus.includes('error')) - return 'deploy_failed' - return 'ready' -} - -export function deployedRows(rows?: RuntimeInstanceRow[]) { - return rows?.filter((row) => { - const runtimeStatus = row.status?.toLowerCase() ?? '' - return row.environment?.id - && !isUndeployedDeploymentRow(row) - && (row.id || runtimeStatus || row.currentRelease || row.detail) - }) ?? [] -} - -export function environmentOptionsFromOptionsReply(response?: ListDeploymentEnvironmentOptionsReply): EnvironmentOption[] { - return response?.environments - ?.filter(environment => environment.id) - .map(environment => ({ - ...environment, - disabled: environment.deployable === false, - })) ?? [] -} - -export function accessModeToPermissionKey(mode?: string): AccessPermissionKind { - const normalized = mode?.toLowerCase() ?? '' - if (normalized === 'private') - return 'specific' - if (normalized === 'public') - return 'anyone' - return 'organization' -} - -export function permissionKeyToAccessMode(key: AccessPermissionKind) { - if (key === 'organization') - return 'private_all' - if (key === 'specific') - return 'private' - return 'public' -} diff --git a/web/features/deployments/webapp-url.ts b/web/features/deployments/webapp-url.ts new file mode 100644 index 0000000000..dcc744a2dc --- /dev/null +++ b/web/features/deployments/webapp-url.ts @@ -0,0 +1,26 @@ +import { PUBLIC_API_PREFIX } from '@/config' + +const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:\/\//i + +function withLeadingSlash(path: string) { + return path.startsWith('/') ? path : `/${path}` +} + +function publicWebappOrigin() { + try { + return new URL(PUBLIC_API_PREFIX).origin + } + catch { + return PUBLIC_API_PREFIX.replace(/\/api\/?$/, '').replace(/\/+$/, '') + } +} + +export function webappUrl(url?: string) { + if (!url) + return '' + if (absoluteUrlRegExp.test(url)) + return url + + const origin = publicWebappOrigin() + return `${origin}${withLeadingSlash(url)}` +}