This commit is contained in:
Stephen Zhou 2026-05-11 21:16:28 +08:00
parent 86fc60debf
commit 6d0d0763b1
No known key found for this signature in database
30 changed files with 295 additions and 343 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
import { AppModeEnum } from '@/types/app'
const appModeValues = new Set<string>(Object.values(AppModeEnum))
export function toAppMode(mode?: string): AppModeEnum {
return appModeValues.has(mode ?? '') ? (mode as AppModeEnum) : AppModeEnum.WORKFLOW
}

View File

@ -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'}`

View File

@ -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<string, string>
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,
})

View File

@ -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

View File

@ -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<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',

View File

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

View File

@ -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'

View File

@ -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<AccessPermissionKind, string> = {
organization: 'i-ri-team-line',

View File

@ -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))

View File

@ -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 && (
<span
className={cn(

View File

@ -4,19 +4,14 @@ import type { ReleaseRuntimeBinding, RuntimeInstanceRow } from '@dify/contracts/
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { environmentBackend, environmentMode } from '../../environment'
import { formatDate, releaseCommit, releaseLabel } from '../../release'
import {
activeRelease,
deploymentId,
environmentBackend,
environmentMode,
formatDate,
isRuntimeEnvVarBinding,
isRuntimeModelBinding,
isRuntimePluginBinding,
releaseCommit,
releaseLabel,
runtimeBindingSummary,
} from '../../utils'
} from '../../runtime-bindings'
function InfoBlock({ title, children }: {
title: string
@ -67,7 +62,7 @@ export function DeploymentPanel({ row }: {
row: RuntimeInstanceRow
}) {
const { t } = useTranslation('deployments')
const observed = activeRelease(row)
const observed = row.currentRelease
const env = row.environment
const endpoints = row.detail?.endpoints
const detailBindings = row.detail?.bindings ?? []
@ -79,7 +74,7 @@ export function DeploymentPanel({ row }: {
<div className="border-t border-divider-subtle bg-background-default-subtle px-4 py-3">
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<InfoBlock title={t('deployTab.panel.instanceInfo')}>
<InfoRow label={t('deployTab.panel.deploymentId')} value={deploymentId(row) || '—'} mono />
<InfoRow label={t('deployTab.panel.deploymentId')} value={row.id || '—'} mono />
<InfoRow label={t('deployTab.panel.replicas')} value={row.detail?.replicas != null ? String(row.detail.replicas) : '—'} />
<InfoRow label={t('deployTab.panel.runtimeMode')} value={row.detail?.runtimeMode ?? t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${environmentBackend(env).toUpperCase()}`} />
<InfoRow label={t('deployTab.panel.runtimeNote')} value={row.detail?.runtimeNote ?? row.status ?? '—'} />

View File

@ -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 (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
<span className="i-ri-loader-4-line size-3.5 animate-spin" />
{t('deployTab.status.deployingRelease', { release: releaseLabel(activeRelease(row)) })}
{t('deployTab.status.deployingRelease', { release: releaseLabel(row.currentRelease) })}
</span>
)
}
if (status === 'deploy_failed') {
const hasRunningRelease = !!activeRelease(row)?.id
const hasRunningRelease = !!row.currentRelease?.id
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-warning-warning-700">
<span className="i-ri-alert-line size-3.5" />

View File

@ -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

View File

@ -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 }: {

View File

@ -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 (
<DeleteInstanceControl

View File

@ -13,16 +13,10 @@ import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentId, environmentName } from '../../environment'
import { releaseDeploymentAction } from '../../release-action'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import {
activeRelease,
deployedRows,
deploymentStatus,
environmentId,
environmentName,
environmentOptionsFromOptionsReply,
releaseDeploymentAction,
} from '../../utils'
export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
appInstanceId: string
@ -42,9 +36,14 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
enabled: open,
}))
const environmentOptions = environmentOptionsFromOptionsReply(environmentOptionsReply)
const environmentOptions = environmentOptionsReply?.environments
?.filter(environment => 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,
})

View File

@ -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),

View File

@ -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 (

View File

@ -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'
}

View File

@ -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[]

View File

@ -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)

View File

@ -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'
}

View File

@ -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 || '—' : '—'
}

View File

@ -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)
}

View File

@ -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'
}

View File

@ -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
}

View File

@ -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<string>(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'
}

View File

@ -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)}`
}