mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
use api for deployments
This commit is contained in:
parent
bea78ade6e
commit
111483c73a
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { AppInfo } from './types'
|
||||
import type { AppDeploymentSummary } from '@/contract/console/deployments'
|
||||
import type { DeploymentAppData } from '@/service/deployments'
|
||||
import type { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@ -98,15 +99,15 @@ const NewInstanceCard: FC<NewInstanceCardProps> = ({ onOpen }) => {
|
||||
type InstanceCardProps = {
|
||||
app: AppInfo
|
||||
appData?: DeploymentAppData
|
||||
summary?: AppDeploymentSummary
|
||||
}
|
||||
|
||||
const InstanceCard: FC<InstanceCardProps> = ({ app, appData }) => {
|
||||
const InstanceCard: FC<InstanceCardProps> = ({ app, appData, summary }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer)
|
||||
const deleteInstance = useDeploymentsStore(state => state.deleteInstance)
|
||||
|
||||
const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`)
|
||||
|
||||
@ -121,19 +122,32 @@ const InstanceCard: FC<InstanceCardProps> = ({ app, appData }) => {
|
||||
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
|
||||
[appData?.environmentDeployments.environmentDeployments],
|
||||
)
|
||||
const envCount = deployments.length
|
||||
const failedCount = deployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
|
||||
const deployingCount = deployments.filter(row => deploymentStatus(row) === 'deploying').length
|
||||
const readyCount = deployments.filter(row => deploymentStatus(row) === 'ready').length
|
||||
const statusCount = (status: string) =>
|
||||
summary?.statusCounts?.find(item => item.status === status)?.count ?? 0
|
||||
const hasSummary = Boolean(summary)
|
||||
const failedCount = hasSummary
|
||||
? statusCount('failed') + statusCount('deploy_failed')
|
||||
: deployments.filter(row => deploymentStatus(row) === 'deploy_failed').length
|
||||
const deployingCount = hasSummary
|
||||
? statusCount('deploying')
|
||||
: deployments.filter(row => deploymentStatus(row) === 'deploying').length
|
||||
const readyCount = hasSummary
|
||||
? statusCount('ready')
|
||||
: deployments.filter(row => deploymentStatus(row) === 'ready').length
|
||||
const envCount = hasSummary
|
||||
? (summary?.deployed ? failedCount + deployingCount + readyCount : 0)
|
||||
: deployments.length
|
||||
|
||||
const lastDeployedAt = useMemo(() => {
|
||||
if (summary?.lastDeployedAt)
|
||||
return new Date(summary.lastDeployedAt).getTime()
|
||||
if (deployments.length === 0)
|
||||
return null
|
||||
return deployments.reduce((latest, row) => {
|
||||
const t = new Date(row.instance?.lastDeployedAt || row.instance?.lastReadyAt || '').getTime()
|
||||
return t > latest ? t : latest
|
||||
}, 0)
|
||||
}, [deployments])
|
||||
}, [deployments, summary?.lastDeployedAt])
|
||||
|
||||
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
|
||||
? 'none'
|
||||
@ -162,29 +176,51 @@ const InstanceCard: FC<InstanceCardProps> = ({ app, appData }) => {
|
||||
return t('status.deployFailed')
|
||||
return t(`status.${status}`)
|
||||
}
|
||||
const statusSummaryLabel = (status?: string) => {
|
||||
if (status === 'failed' || status === 'deploy_failed')
|
||||
return t('status.deployFailed')
|
||||
if (status === 'deploying')
|
||||
return t('status.deploying')
|
||||
if (status === 'ready')
|
||||
return t('status.ready')
|
||||
return status || 'unknown'
|
||||
}
|
||||
|
||||
const statusSummaryTooltip = summary?.statusCounts?.filter(item => item.count && item.status !== 'undeployed') ?? []
|
||||
const statusTooltip = primaryStatus === 'none'
|
||||
? t('card.tooltip.notDeployed')
|
||||
: (
|
||||
<div className="flex min-w-[220px] flex-col gap-1">
|
||||
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
||||
{deployments.map((deployment) => {
|
||||
const status = deploymentStatus(deployment)
|
||||
return (
|
||||
<div key={environmentId(deployment.environment)} className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-text-tertiary">
|
||||
{environmentName(deployment.environment)}
|
||||
</span>
|
||||
<span className="shrink-0 text-text-secondary">
|
||||
{statusLabel(status)}
|
||||
{' · '}
|
||||
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
|
||||
</span>
|
||||
: deployments.length > 0
|
||||
? (
|
||||
<div className="flex min-w-[220px] flex-col gap-1">
|
||||
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
||||
{deployments.map((deployment) => {
|
||||
const status = deploymentStatus(deployment)
|
||||
return (
|
||||
<div key={environmentId(deployment.environment)} className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-text-tertiary">
|
||||
{environmentName(deployment.environment)}
|
||||
</span>
|
||||
<span className="shrink-0 text-text-secondary">
|
||||
{statusLabel(status)}
|
||||
{' · '}
|
||||
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex min-w-[180px] flex-col gap-1">
|
||||
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
||||
{statusSummaryTooltip.map(item => (
|
||||
<div key={item.status} className="flex justify-between gap-3">
|
||||
<span className="text-text-tertiary">{statusSummaryLabel(item.status)}</span>
|
||||
<span className="text-text-secondary">{item.count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const healthPillClass = primaryStatus === 'none'
|
||||
? 'text-text-tertiary bg-background-section-burn'
|
||||
@ -314,8 +350,13 @@ const InstanceCard: FC<InstanceCardProps> = ({ app, appData }) => {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={e => handleMenuAction(e, () => deleteInstance(app.id))}
|
||||
aria-disabled
|
||||
title={t('card.menu.deleteDisabled')}
|
||||
className="cursor-not-allowed gap-2 px-3 opacity-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-destructive">{t('card.menu.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
@ -332,6 +373,8 @@ type EnvironmentFilterOption = {
|
||||
value: string
|
||||
text: string
|
||||
icon: React.ReactNode
|
||||
disabled?: boolean
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
type EnvironmentFilterProps = {
|
||||
@ -373,10 +416,19 @@ const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ value, options, onChang
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (option.disabled)
|
||||
return
|
||||
onChange(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none hover:bg-state-base-hover"
|
||||
title={option.disabled ? option.disabledReason : undefined}
|
||||
aria-disabled={option.disabled}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none',
|
||||
option.disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
|
||||
<span className="grow truncate text-sm leading-5 text-text-tertiary">{option.text}</span>
|
||||
@ -394,7 +446,6 @@ const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ value, options, onChang
|
||||
|
||||
const DeploymentsMain: FC = () => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const sourceApps = useDeploymentsStore(state => state.sourceApps)
|
||||
const appData = useDeploymentsStore(state => state.appData)
|
||||
const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal)
|
||||
|
||||
@ -417,29 +468,28 @@ const DeploymentsMain: FC = () => {
|
||||
commitKeywords(next)
|
||||
}
|
||||
|
||||
const { appMap } = useSourceApps()
|
||||
const apps = useMemo(
|
||||
() => sourceApps.length > 0 ? sourceApps : [...appMap.values()],
|
||||
[appMap, sourceApps],
|
||||
)
|
||||
const appDataList = useMemo(() => Object.values(appData), [appData])
|
||||
const requestedEnvironmentId = envFilter !== 'all' && envFilter !== 'not-deployed'
|
||||
? envFilter
|
||||
: undefined
|
||||
const {
|
||||
apps,
|
||||
summaries,
|
||||
environmentOptions,
|
||||
} = useSourceApps({
|
||||
environmentId: requestedEnvironmentId,
|
||||
keyword: keywords.trim() || undefined,
|
||||
})
|
||||
|
||||
const environments = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
appDataList.forEach((data) => {
|
||||
data.candidates.environmentOptions?.forEach((env) => {
|
||||
const id = environmentId(env)
|
||||
if (id)
|
||||
map.set(id, environmentName(env))
|
||||
})
|
||||
data.environmentDeployments.environmentDeployments?.forEach((row) => {
|
||||
const id = environmentId(row.environment)
|
||||
if (id)
|
||||
map.set(id, environmentName(row.environment))
|
||||
})
|
||||
})
|
||||
return [...map.entries()].map(([id, name]) => ({ id, name }))
|
||||
}, [appDataList])
|
||||
return environmentOptions
|
||||
.filter(env => environmentId(env))
|
||||
.map(env => ({
|
||||
id: environmentId(env),
|
||||
name: environmentName(env),
|
||||
disabled: env.disabled,
|
||||
disabledReason: env.disabledReason,
|
||||
}))
|
||||
}, [environmentOptions])
|
||||
|
||||
const envIdSet = useMemo(() => new Set(environments.map(e => e.id)), [environments])
|
||||
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed' || envIdSet.has(envFilter)
|
||||
@ -457,6 +507,8 @@ const DeploymentsMain: FC = () => {
|
||||
value: env.id,
|
||||
text: env.name,
|
||||
icon: <span className="i-ri-stack-line h-[14px] w-[14px]" />,
|
||||
disabled: env.disabled,
|
||||
disabledReason: env.disabledReason,
|
||||
})),
|
||||
{
|
||||
value: 'not-deployed',
|
||||
@ -467,22 +519,10 @@ const DeploymentsMain: FC = () => {
|
||||
}, [environments, t])
|
||||
|
||||
const visibleInstances = useMemo(() => {
|
||||
const byEnv = activeFilter === 'all'
|
||||
? apps
|
||||
: activeFilter === 'not-deployed'
|
||||
? apps.filter(app => deployedRows(appData[app.id]?.environmentDeployments.environmentDeployments).length === 0)
|
||||
: apps.filter(app => deployedRows(appData[app.id]?.environmentDeployments.environmentDeployments).some(row => environmentId(row.environment) === activeFilter))
|
||||
|
||||
const q = keywords.trim().toLowerCase()
|
||||
if (!q)
|
||||
return byEnv
|
||||
return byEnv.filter((app) => {
|
||||
return (
|
||||
app.name.toLowerCase().includes(q)
|
||||
|| (app.description ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [apps, activeFilter, keywords, appData])
|
||||
return activeFilter === 'not-deployed'
|
||||
? apps.filter(app => summaries[app.id]?.deployed === false)
|
||||
: apps
|
||||
}, [apps, activeFilter, summaries])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -513,6 +553,7 @@ const DeploymentsMain: FC = () => {
|
||||
key={app.id}
|
||||
app={app}
|
||||
appData={appData[app.id]}
|
||||
summary={summaries[app.id]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -2,11 +2,8 @@
|
||||
import type { FC } from 'react'
|
||||
import type { AppInfo } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { deployedRows } from '../api-utils'
|
||||
import { useDeploymentsStore } from '../store'
|
||||
import { useSourceApps } from '../use-source-apps'
|
||||
@ -22,43 +19,12 @@ type SettingsFormProps = {
|
||||
|
||||
const SettingsForm: FC<SettingsFormProps> = ({ app, hasDeployments }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const updateInstance = useDeploymentsStore(state => state.updateInstance)
|
||||
const deleteInstance = useDeploymentsStore(state => state.deleteInstance)
|
||||
|
||||
const [name, setName] = useState(app.name)
|
||||
const [description, setDescription] = useState(app.description ?? '')
|
||||
|
||||
const dirty = name !== app.name || description !== (app.description ?? '')
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim())
|
||||
return
|
||||
updateInstance(app.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
})
|
||||
toast.success(t('settings.updated'))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setName(app.name)
|
||||
setDescription(app.description ?? '')
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (hasDeployments) {
|
||||
toast.error(t('settings.undeployFirst'))
|
||||
return
|
||||
}
|
||||
deleteInstance(app.id)
|
||||
router.push('/deployments')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[640px] flex-col gap-5 p-6">
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4">
|
||||
<div className="system-sm-semibold text-text-primary">{t('settings.general')}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{t('settings.readOnly')}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-name">
|
||||
{t('settings.name')}
|
||||
@ -66,9 +32,10 @@ const SettingsForm: FC<SettingsFormProps> = ({ app, hasDeployments }) => {
|
||||
<input
|
||||
id="settings-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 text-[13px] font-medium text-text-secondary outline-hidden placeholder:text-text-quaternary"
|
||||
value={app.name}
|
||||
readOnly
|
||||
disabled
|
||||
className="flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 text-[13px] font-medium text-text-secondary outline-hidden placeholder:text-text-quaternary disabled:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@ -77,16 +44,17 @@ const SettingsForm: FC<SettingsFormProps> = ({ app, hasDeployments }) => {
|
||||
</label>
|
||||
<textarea
|
||||
id="settings-desc"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="min-h-[96px] rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 py-2 text-[13px] text-text-secondary outline-hidden placeholder:text-text-quaternary"
|
||||
value={app.description ?? ''}
|
||||
readOnly
|
||||
disabled
|
||||
className="min-h-[96px] rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 py-2 text-[13px] text-text-secondary outline-hidden placeholder:text-text-quaternary disabled:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" disabled={!dirty} onClick={handleReset}>
|
||||
<Button variant="secondary" disabled>
|
||||
{t('settings.reset')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!dirty || !name.trim()} onClick={handleSave}>
|
||||
<Button variant="primary" disabled>
|
||||
{t('settings.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@ -101,13 +69,12 @@ const SettingsForm: FC<SettingsFormProps> = ({ app, hasDeployments }) => {
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{hasDeployments
|
||||
? t('settings.undeployFirst')
|
||||
: t('settings.safeToDelete')}
|
||||
: t('settings.deleteUnsupported')}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="destructive"
|
||||
disabled={hasDeployments}
|
||||
onClick={handleDelete}
|
||||
disabled
|
||||
>
|
||||
{t('settings.delete')}
|
||||
</Button>
|
||||
|
||||
@ -154,23 +154,11 @@ export const useDeploymentsStore = create<DeploymentsState>((set, get) => ({
|
||||
return appId
|
||||
},
|
||||
|
||||
updateInstance: (appId, patch) => {
|
||||
set(state => ({
|
||||
sourceApps: state.sourceApps.map(app => app.id === appId ? { ...app, ...patch } : app),
|
||||
}))
|
||||
},
|
||||
updateInstance: () => undefined,
|
||||
|
||||
switchSourceApp: () => undefined,
|
||||
|
||||
deleteInstance: (appId) => {
|
||||
set(state => ({
|
||||
sourceApps: state.sourceApps.filter(app => app.id !== appId),
|
||||
createdApiToken: state.createdApiToken?.appId === appId ? undefined : state.createdApiToken,
|
||||
appData: Object.fromEntries(
|
||||
Object.entries(state.appData).filter(([key]) => key !== appId),
|
||||
),
|
||||
}))
|
||||
},
|
||||
deleteInstance: () => undefined,
|
||||
|
||||
startDeploy: async ({ appId, environmentId, releaseId, releaseNote, bindings }) => {
|
||||
set({ deployDrawer: { open: false } })
|
||||
|
||||
@ -3,7 +3,7 @@ export type EnvironmentHealth = 'ready' | 'degraded'
|
||||
|
||||
export type DeployStatus = 'ready' | 'deploying' | 'deploy_failed'
|
||||
|
||||
export type AppMode = 'chat' | 'agent-chat' | 'workflow' | 'completion' | 'advanced-chat'
|
||||
export type AppMode = 'chat' | 'agent-chat' | 'workflow' | 'completion' | 'advanced-chat' | (string & {})
|
||||
|
||||
export type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
|
||||
|
||||
|
||||
@ -1,60 +1,89 @@
|
||||
'use client'
|
||||
import type { AppInfo, AppMode } from './types'
|
||||
import type { App } from '@/types/app'
|
||||
import type { AppDeploymentSummary, ConsoleAppSummary, EnvironmentOption } from '@/contract/console/deployments'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useAppList } from '@/service/use-apps'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useDeploymentsStore } from './store'
|
||||
import { useDeploymentData } from './use-deployment-data'
|
||||
|
||||
const MAX_SOURCE_APPS = 100
|
||||
|
||||
function toAppInfo(app: App): AppInfo {
|
||||
function toAppInfo(app: ConsoleAppSummary): AppInfo | undefined {
|
||||
if (!app.id || !app.name)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
mode: app.mode as AppMode,
|
||||
iconType: app.icon_type === 'image' ? 'image' : 'emoji',
|
||||
mode: (app.mode || 'workflow') as AppMode,
|
||||
iconType: 'emoji',
|
||||
icon: app.icon,
|
||||
iconBackground: app.icon_background ?? undefined,
|
||||
iconUrl: app.icon_url,
|
||||
description: app.description,
|
||||
description: app.description ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type UseSourceAppsOptions = {
|
||||
enabled?: boolean
|
||||
environmentId?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export function useSourceApps(options: UseSourceAppsOptions = {}) {
|
||||
const { enabled = true } = options
|
||||
const { enabled = true, environmentId, keyword } = options
|
||||
const seedInstancesFromApps = useDeploymentsStore(state => state.seedInstancesFromApps)
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useAppList({
|
||||
page: 1,
|
||||
limit: MAX_SOURCE_APPS,
|
||||
}, { enabled })
|
||||
const query = useMemo(() => ({
|
||||
pageNumber: 1,
|
||||
resultsPerPage: MAX_SOURCE_APPS,
|
||||
...(environmentId ? { environmentId } : {}),
|
||||
...(keyword?.trim() ? { keyword: keyword.trim() } : {}),
|
||||
}), [environmentId, keyword])
|
||||
|
||||
const listQuery = useQuery(consoleQuery.deployments.list.queryOptions({
|
||||
input: { query },
|
||||
enabled,
|
||||
staleTime: 30 * 1000,
|
||||
}))
|
||||
|
||||
const apps = useMemo<AppInfo[]>(() => {
|
||||
return (data?.data ?? []).map(toAppInfo)
|
||||
}, [data?.data])
|
||||
return (listQuery.data?.data ?? [])
|
||||
.map(summary => summary.app ? toAppInfo(summary.app) : undefined)
|
||||
.filter((app): app is AppInfo => Boolean(app))
|
||||
}, [listQuery.data?.data])
|
||||
|
||||
const appMap = useMemo<Map<string, AppInfo>>(() => {
|
||||
return new Map(apps.map(a => [a.id, a]))
|
||||
}, [apps])
|
||||
|
||||
const summaries = useMemo<Record<string, AppDeploymentSummary>>(() => {
|
||||
return Object.fromEntries(
|
||||
(listQuery.data?.data ?? [])
|
||||
.filter(summary => summary.app?.id)
|
||||
.map(summary => [summary.app!.id!, summary]),
|
||||
)
|
||||
}, [listQuery.data?.data])
|
||||
|
||||
const environmentOptions = useMemo<EnvironmentOption[]>(() => {
|
||||
return listQuery.data?.environmentOptions ?? []
|
||||
}, [listQuery.data?.environmentOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (apps.length > 0)
|
||||
seedInstancesFromApps(apps)
|
||||
}, [apps, seedInstancesFromApps])
|
||||
if (!enabled || listQuery.isLoading)
|
||||
return
|
||||
seedInstancesFromApps(apps)
|
||||
}, [apps, enabled, listQuery.isLoading, seedInstancesFromApps])
|
||||
|
||||
const deploymentData = useDeploymentData(apps, { enabled: enabled && apps.length > 0 })
|
||||
|
||||
return {
|
||||
apps,
|
||||
appMap,
|
||||
isLoading: isLoading || deploymentData.isLoading,
|
||||
isFetching: isFetching || deploymentData.isFetching,
|
||||
isError: isError || deploymentData.isError,
|
||||
isEmpty: !isLoading && apps.length === 0,
|
||||
summaries,
|
||||
environmentOptions,
|
||||
isLoading: listQuery.isLoading,
|
||||
isFetching: listQuery.isFetching || deploymentData.isFetching,
|
||||
isError: listQuery.isError || deploymentData.isError,
|
||||
isEmpty: !listQuery.isLoading && apps.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,6 +70,24 @@ export type ConsoleWarning = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type DeploymentStatusCount = {
|
||||
status?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export type AppDeploymentSummary = {
|
||||
app?: ConsoleAppSummary
|
||||
statusCounts?: DeploymentStatusCount[]
|
||||
deployed?: boolean
|
||||
lastDeployedAt?: Timestamp | null
|
||||
}
|
||||
|
||||
export type ListAppDeploymentsReply = {
|
||||
data?: AppDeploymentSummary[]
|
||||
environmentOptions?: EnvironmentOption[]
|
||||
pagination?: Pagination
|
||||
}
|
||||
|
||||
export type DeploymentSummaryRow = {
|
||||
environmentId?: string
|
||||
environmentName?: string
|
||||
@ -404,10 +422,15 @@ export type BindingsProto = {
|
||||
envVars?: EnvVarBindingProto[]
|
||||
}
|
||||
|
||||
export type CreateReleaseFromCurrentApp = {
|
||||
releaseNote?: string
|
||||
}
|
||||
|
||||
export type CreateDeploymentReply = {
|
||||
instanceId?: string
|
||||
deploymentId?: string
|
||||
status?: string
|
||||
release?: Release
|
||||
}
|
||||
|
||||
export type CancelDeploymentReply = {
|
||||
@ -443,6 +466,21 @@ export type CreateEnvironmentAPITokenReply = {
|
||||
|
||||
export type DeleteEnvironmentAPITokenReply = Record<string, never>
|
||||
|
||||
export const listAppDeploymentsContract = base
|
||||
.route({
|
||||
path: '/enterprise/deployments',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
query?: {
|
||||
environmentId?: string
|
||||
keyword?: string
|
||||
pageNumber?: number
|
||||
resultsPerPage?: number
|
||||
}
|
||||
}>())
|
||||
.output(type<ListAppDeploymentsReply>())
|
||||
|
||||
export const deploymentOverviewContract = base
|
||||
.route({
|
||||
path: '/enterprise/apps/{appId}/deploy/overview',
|
||||
@ -601,7 +639,8 @@ export const createDeploymentContract = base
|
||||
environmentId: string
|
||||
}
|
||||
body: {
|
||||
releaseId: string
|
||||
releaseId?: string
|
||||
currentApp?: CreateReleaseFromCurrentApp
|
||||
bindings?: BindingsProto
|
||||
replicas?: number
|
||||
idempotencyKey?: string
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
environmentAccessPolicyContract,
|
||||
environmentAPITokensContract,
|
||||
environmentDeploymentsContract,
|
||||
listAppDeploymentsContract,
|
||||
patchAccessChannelContract,
|
||||
releaseHistoryContract,
|
||||
rollbackEnvironmentContract,
|
||||
@ -112,6 +113,7 @@ export const consoleRouterContract = {
|
||||
bindPartnerStack: bindPartnerStackContract,
|
||||
},
|
||||
deployments: {
|
||||
list: listAppDeploymentsContract,
|
||||
overview: deploymentOverviewContract,
|
||||
environmentDeployments: environmentDeploymentsContract,
|
||||
candidates: deploymentCandidatesContract,
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
"card.fromApp": "From {{name}}",
|
||||
"card.lastDeployed": "Last deployed {{time}}",
|
||||
"card.menu.delete": "Delete instance",
|
||||
"card.menu.deleteDisabled": "Instance deletion is not available for backend-managed deployments yet.",
|
||||
"card.menu.deploy": "Deploy to an environment",
|
||||
"card.menu.viewDetail": "View instance detail",
|
||||
"card.moreActions": "More actions",
|
||||
@ -235,11 +236,13 @@
|
||||
"rollback.sourceApp": "Source app",
|
||||
"rollback.title": "Deploy {{release}}",
|
||||
"settings.danger": "Danger zone",
|
||||
"settings.dangerDesc": "Deleting an instance removes all associated API keys, access config and deployment history. This action cannot be undone.",
|
||||
"settings.dangerDesc": "Backend-managed deployment instances cannot be deleted from the console yet.",
|
||||
"settings.delete": "Delete instance",
|
||||
"settings.deleteUnsupported": "Instance deletion is not available for backend-managed deployments yet.",
|
||||
"settings.description": "Description",
|
||||
"settings.general": "General",
|
||||
"settings.name": "Instance name",
|
||||
"settings.readOnly": "Instance metadata is read from the source Studio app.",
|
||||
"settings.reset": "Reset",
|
||||
"settings.safeToDelete": "No active deployments. Safe to delete.",
|
||||
"settings.save": "Save changes",
|
||||
@ -255,7 +258,7 @@
|
||||
"tabs.deploy.name": "Deploy",
|
||||
"tabs.overview.description": "Summary of this app instance across all environments.",
|
||||
"tabs.overview.name": "Overview",
|
||||
"tabs.settings.description": "Edit instance name, description and other preferences.",
|
||||
"tabs.settings.description": "View source app metadata and backend-managed settings.",
|
||||
"tabs.settings.name": "Settings",
|
||||
"tabs.versions.description": "All releases for this app. Deploy any release to an environment.",
|
||||
"tabs.versions.name": "Versions",
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
"card.fromApp": "来自 {{name}}",
|
||||
"card.lastDeployed": "上次部署于 {{time}}",
|
||||
"card.menu.delete": "删除实例",
|
||||
"card.menu.deleteDisabled": "后端托管的部署暂不支持删除实例。",
|
||||
"card.menu.deploy": "部署到环境",
|
||||
"card.menu.viewDetail": "查看实例详情",
|
||||
"card.moreActions": "更多操作",
|
||||
@ -235,11 +236,13 @@
|
||||
"rollback.sourceApp": "源应用",
|
||||
"rollback.title": "部署 {{release}}",
|
||||
"settings.danger": "危险区域",
|
||||
"settings.dangerDesc": "删除实例会一并删除所有关联的 API 密钥、接入配置和部署历史。此操作无法撤销。",
|
||||
"settings.dangerDesc": "后端托管的部署实例暂不能在控制台删除。",
|
||||
"settings.delete": "删除实例",
|
||||
"settings.deleteUnsupported": "后端托管的部署暂不支持删除实例。",
|
||||
"settings.description": "描述",
|
||||
"settings.general": "常规",
|
||||
"settings.name": "实例名称",
|
||||
"settings.readOnly": "实例元数据来自源 Studio 应用,当前以只读方式展示。",
|
||||
"settings.reset": "重置",
|
||||
"settings.safeToDelete": "无活动部署,可安全删除。",
|
||||
"settings.save": "保存修改",
|
||||
@ -255,7 +258,7 @@
|
||||
"tabs.deploy.name": "部署",
|
||||
"tabs.overview.description": "该应用实例在所有环境中的概览。",
|
||||
"tabs.overview.name": "概览",
|
||||
"tabs.settings.description": "编辑实例名称、描述及其它偏好。",
|
||||
"tabs.settings.description": "查看源应用元数据和后端托管设置。",
|
||||
"tabs.settings.name": "设置",
|
||||
"tabs.versions.description": "此应用的所有发布版本,可将任一版本发布到环境。",
|
||||
"tabs.versions.name": "版本",
|
||||
|
||||
@ -30,8 +30,6 @@ export type CreateDeploymentParams = {
|
||||
|
||||
const idempotencyKey = (prefix: string) => `${prefix}-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`
|
||||
|
||||
const defaultReleaseName = () => `deploy-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
|
||||
|
||||
export const fetchDeploymentAppData = async (appId: string): Promise<DeploymentAppData> => {
|
||||
const input = { params: { appId } }
|
||||
const [
|
||||
@ -70,29 +68,6 @@ export const fetchDeploymentAppData = async (appId: string): Promise<DeploymentA
|
||||
}
|
||||
}
|
||||
|
||||
export const createOrReuseRelease = async (
|
||||
appId: string,
|
||||
releaseId: string | undefined,
|
||||
releaseNote: string | undefined,
|
||||
) => {
|
||||
if (releaseId)
|
||||
return releaseId
|
||||
|
||||
const trimmedNote = releaseNote?.trim()
|
||||
const response = await consoleClient.deployments.createRelease({
|
||||
params: { appId },
|
||||
body: {
|
||||
description: trimmedNote || undefined,
|
||||
name: trimmedNote || defaultReleaseName(),
|
||||
},
|
||||
})
|
||||
|
||||
const createdReleaseId = response.release?.id
|
||||
if (!createdReleaseId)
|
||||
throw new Error('Release creation did not return an id.')
|
||||
return createdReleaseId
|
||||
}
|
||||
|
||||
export const createDeployment = async ({
|
||||
appId,
|
||||
environmentId,
|
||||
@ -100,14 +75,16 @@ export const createDeployment = async ({
|
||||
releaseNote,
|
||||
bindings,
|
||||
}: CreateDeploymentParams) => {
|
||||
const targetReleaseId = await createOrReuseRelease(appId, releaseId, releaseNote)
|
||||
const trimmedReleaseNote = releaseNote?.trim()
|
||||
return consoleClient.deployments.createDeployment({
|
||||
params: {
|
||||
appId,
|
||||
environmentId,
|
||||
},
|
||||
body: {
|
||||
releaseId: targetReleaseId,
|
||||
...(releaseId
|
||||
? { releaseId }
|
||||
: { currentApp: { releaseNote: trimmedReleaseNote || undefined } }),
|
||||
bindings,
|
||||
idempotencyKey: idempotencyKey('deploy'),
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user