From bea78ade6e565ac3754ff867fa5636f036cc6f37 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:28:44 +0800 Subject: [PATCH] use real api --- web/app/components/deployments/api-utils.ts | 107 +++ .../deployments/create-instance-modal.tsx | 2 +- .../components/deployments/deploy-drawer.tsx | 340 +++++---- web/app/components/deployments/index.tsx | 106 +-- .../instance-detail/access-tab.tsx | 211 +++--- .../instance-detail/deploy-tab.tsx | 301 +++----- .../deployments/instance-detail/index.tsx | 28 +- .../instance-detail/overview-tab.tsx | 378 +++------- .../instance-detail/settings-tab.tsx | 38 +- .../instance-detail/versions-tab.tsx | 241 +++--- web/app/components/deployments/mock-data.ts | 407 ---------- .../components/deployments/rollback-modal.tsx | 43 +- web/app/components/deployments/store.ts | 487 ++++-------- web/app/components/deployments/types.ts | 109 +-- .../deployments/use-deployment-data.ts | 42 ++ .../components/deployments/use-source-apps.ts | 9 +- .../header/deployments-nav/index.tsx | 27 +- web/contract/console/deployments.ts | 703 ++++++++++++++++++ web/contract/router.ts | 40 + web/i18n/en-US/deployments.json | 4 + web/i18n/zh-Hans/deployments.json | 4 + web/plugins/dev-proxy/cookies.ts | 9 +- web/plugins/dev-proxy/server.spec.ts | 26 + web/plugins/dev-proxy/server.ts | 4 +- web/service/deployments.ts | 214 ++++++ 25 files changed, 2043 insertions(+), 1837 deletions(-) create mode 100644 web/app/components/deployments/api-utils.ts delete mode 100644 web/app/components/deployments/mock-data.ts create mode 100644 web/app/components/deployments/use-deployment-data.ts create mode 100644 web/contract/console/deployments.ts create mode 100644 web/service/deployments.ts diff --git a/web/app/components/deployments/api-utils.ts b/web/app/components/deployments/api-utils.ts new file mode 100644 index 0000000000..554f1e8ea3 --- /dev/null +++ b/web/app/components/deployments/api-utils.ts @@ -0,0 +1,107 @@ +import type { AccessPermissionKind } from './types' +import type { + ConsoleEnvironmentSummary, + ConsoleReleaseSummary, + EnvironmentDeploymentRow, + EnvironmentOption, +} from '@/contract/console/deployments' +import { PUBLIC_API_PREFIX } from '@/config' + +export type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed' + +export const formatDate = (value?: string) => { + if (!value) + return '—' + return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16) +} + +export const environmentId = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => environment?.id ?? '' + +export const environmentName = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => environment?.name || environment?.id || '—' + +export const environmentMode = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => { + const type = environment?.type?.toLowerCase() ?? '' + return type.includes('isolated') ? 'isolated' : 'shared' +} + +export const environmentBackend = (environment?: ConsoleEnvironmentSummary) => { + const runtime = environment?.runtime?.toLowerCase() ?? '' + return runtime.includes('host') ? 'host' : 'k8s' +} + +export const environmentHealth = (environment?: ConsoleEnvironmentSummary | EnvironmentOption) => { + const status = environment?.status?.toLowerCase() ?? '' + return status.includes('ready') ? 'ready' : 'degraded' +} + +export const releaseId = (release?: ConsoleReleaseSummary) => release?.id ?? '' + +export const releaseLabel = (release?: ConsoleReleaseSummary) => release?.displayId || release?.id || '—' + +export const releaseCommit = (release?: ConsoleReleaseSummary) => release?.commitId || '—' + +const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:\/\//i + +const withLeadingSlash = (path: string) => path.startsWith('/') ? path : `/${path}` + +const publicWebappOrigin = () => { + try { + return new URL(PUBLIC_API_PREFIX).origin + } + catch { + return PUBLIC_API_PREFIX.replace(/\/api\/?$/, '').replace(/\/+$/, '') + } +} + +export const webappUrl = (url?: string) => { + if (!url) + return '' + if (absoluteUrlRegExp.test(url)) + return url + + const origin = publicWebappOrigin() + return `${origin}${withLeadingSlash(url)}` +} + +export const deploymentId = (row?: EnvironmentDeploymentRow) => + row?.pendingDeployment?.deploymentId || row?.instance?.currentDeploymentId || '' + +export const activeRelease = (row?: EnvironmentDeploymentRow) => row?.observedRuntime?.release + +export const targetRelease = (row?: EnvironmentDeploymentRow) => row?.pendingDeployment?.release + +export const failedReleaseId = (row?: EnvironmentDeploymentRow) => row?.instance?.lastError?.releaseId + +export const deploymentStatus = (row: EnvironmentDeploymentRow): DeploymentUiStatus => { + if (row.pendingDeployment) + return 'deploying' + if (row.instance?.lastError) + return 'deploy_failed' + + const status = row.instance?.status?.toLowerCase() ?? '' + if (status.includes('deploying') || status.includes('pending')) + return 'deploying' + if (status.includes('fail') || status.includes('error')) + return 'deploy_failed' + return 'ready' +} + +export const deployedRows = (rows?: EnvironmentDeploymentRow[]) => + rows?.filter(row => row.environment?.id && (row.instance || row.observedRuntime || row.pendingDeployment)) ?? [] + +export const accessModeToPermissionKey = (mode?: string): AccessPermissionKind => { + const normalized = mode?.toLowerCase() ?? '' + if (normalized === 'private') + return 'specific' + if (normalized === 'public') + return 'anyone' + return 'organization' +} + +export const permissionKeyToAccessMode = (key: AccessPermissionKind) => { + if (key === 'organization') + return 'private_all' + if (key === 'specific') + return 'private' + return 'public' +} diff --git a/web/app/components/deployments/create-instance-modal.tsx b/web/app/components/deployments/create-instance-modal.tsx index 7ea7934d11..38d3e96dfe 100644 --- a/web/app/components/deployments/create-instance-modal.tsx +++ b/web/app/components/deployments/create-instance-modal.tsx @@ -207,7 +207,7 @@ const CreateInstanceForm: FC<{ onClose: () => void }> = ({ onClose }) => { description: description.trim() || undefined, }) if (thenDeploy) { - openDeployDrawer({ instanceId }) + openDeployDrawer({ appId: instanceId }) return } router.push(`/deployments/${instanceId}/overview`) diff --git a/web/app/components/deployments/deploy-drawer.tsx b/web/app/components/deployments/deploy-drawer.tsx index d33cbbac78..b3fdefcda0 100644 --- a/web/app/components/deployments/deploy-drawer.tsx +++ b/web/app/components/deployments/deploy-drawer.tsx @@ -1,51 +1,109 @@ 'use client' import type { FC } from 'react' -import type { CredentialBinding, Deployment, Environment, EnvVariable, Instance, Release } from './types' +import type { BindingsProto, ConsoleReleaseSummary, DeploymentSlot, EnvironmentOption } from '@/contract/console/deployments' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { skipToken, useQuery } from '@tanstack/react-query' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { mockCredentials } from './mock-data' +import { consoleQuery } from '@/service/client' +import { environmentHealth, environmentMode, environmentName, releaseCommit, releaseLabel } from './api-utils' import { HealthBadge, ModeBadge } from './status-badge' import { useDeploymentsStore } from './store' -type RequiredBindings = { - model: string[] - plugin: string[] - envVars: { key: string, type: 'string' | 'secret' }[] +type CredentialRequirement = { + slot: string + label: string + required: boolean + selectedCredentialId?: string + options: { id: string, label: string }[] } -function deriveRequiredBindings(appId: string): RequiredBindings { - switch (appId) { - case 'app-payments-workflow': - return { - model: ['OpenAI', 'DeepSeek'], - plugin: ['Gmail', 'Notion'], - envVars: [ - { key: 'kn', type: 'string' }, - { key: 'dbkey', type: 'secret' }, - ], - } - case 'app-customer-support': - return { - model: ['OpenAI'], - plugin: ['Gmail'], - envVars: [ - { key: 'dbkey', type: 'secret' }, - { key: 'keyno', type: 'string' }, - ], - } - default: - return { - model: ['OpenAI'], - plugin: [], - envVars: [], - } +type EnvVarRequirement = { + key: string + label: string + required: boolean + selectedEnvVarId?: string + type: 'string' | 'secret' + options: { id: string, label: string }[] +} + +type RequiredBindings = { + model: CredentialRequirement[] + plugin: CredentialRequirement[] + envVars: EnvVarRequirement[] +} + +function isModelSlot(kind?: string) { + return kind?.toLowerCase().includes('model') +} + +function isEnvVarSlot(kind?: string) { + const normalized = kind?.toLowerCase() ?? '' + return normalized.includes('env') +} + +function isSecretValue(type?: string) { + return type?.toLowerCase().includes('secret') ?? false +} + +function deriveRequiredBindings(slots: DeploymentSlot[] | undefined): RequiredBindings { + const required: RequiredBindings = { + model: [], + plugin: [], + envVars: [], } + + slots?.forEach((slot) => { + const slotName = slot.slot || slot.label + if (!slotName) + return + + if (isEnvVarSlot(slot.kind)) { + required.envVars.push({ + key: slotName, + label: slot.label || slotName, + required: slot.required ?? true, + selectedEnvVarId: slot.selectedEnvVarId, + type: isSecretValue(slot.envVarOptions?.[0]?.valueType) ? 'secret' : 'string', + options: slot.envVarOptions + ?.filter(option => option.id) + .map(option => ({ + id: option.id!, + label: `${option.name || option.id}${option.maskedValue ? ` · ${option.maskedValue}` : ''}`, + })) ?? [], + }) + return + } + + const target = isModelSlot(slot.kind) ? required.model : required.plugin + target.push({ + slot: slotName, + label: slot.label || slotName, + required: slot.required ?? true, + selectedCredentialId: slot.selectedCredentialId, + options: slot.credentialOptions + ?.filter(option => option.id) + .map(option => ({ + id: option.id!, + label: option.displayName || option.provider || option.id!, + })) ?? [], + }) + }) + + return required +} + +function credentialValue(values: Record, item: CredentialRequirement) { + return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || '' +} + +function envVarValue(values: Record, item: EnvVarRequirement) { + return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || '' } type FieldProps = { @@ -121,24 +179,24 @@ const LabeledSelect: FC = ({ label, ...rest }) => ( ) -type EnvironmentRowProps = { env: Environment } +type EnvironmentRowProps = { env: EnvironmentOption } const EnvironmentRow: FC = ({ env }) => (
- {env.name} - - + {environmentName(env)} + +
- {env.backend} + {env.type ?? 'env'}
) type DeployFormProps = { - instance: Instance - environments: Environment[] - releases: Release[] - deployments: Deployment[] + appId: string + environments: EnvironmentOption[] + releases: ConsoleReleaseSummary[] + defaultReleaseId?: string lockedEnvId?: string presetReleaseId?: string onCancel: () => void @@ -146,84 +204,56 @@ type DeployFormProps = { environmentId: string releaseId?: string releaseNote?: string - credentials: CredentialBinding[] - envVariables: EnvVariable[] + bindings?: BindingsProto }) => void } const DeployForm: FC = ({ - instance, + appId, environments, releases, - deployments, + defaultReleaseId, lockedEnvId, presetReleaseId, onCancel, onSubmit, }) => { const { t } = useTranslation('deployments') - const bindingProfileId = instance.bindingProfileId ?? instance.appId - const required = useMemo(() => deriveRequiredBindings(bindingProfileId), [bindingProfileId]) - - const credentialsByProvider = useMemo(() => { - const map = new Map() - mockCredentials.forEach((c) => { - const list = map.get(c.provider) ?? [] - list.push(c) - map.set(c.provider, list) - }) - return map - }, []) - const presetRelease = useMemo( () => presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined, [releases, presetReleaseId], ) const isPromote = Boolean(presetRelease) - const existingDeployment = useMemo( - () => lockedEnvId - ? deployments.find(d => d.instanceId === instance.id && d.environmentId === lockedEnvId) - : undefined, - [deployments, instance.id, lockedEnvId], - ) - const [selectedEnvId, setSelectedEnvId] = useState( () => lockedEnvId ?? environments[0]?.id ?? '', ) + const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || '' + const planReleaseId = presetRelease?.id ?? defaultReleaseId ?? releases[0]?.id + const deploymentPlan = useQuery(consoleQuery.deployments.deploymentPlan.queryOptions({ + input: selectedEnvironmentId && planReleaseId + ? { + params: { + appId, + environmentId: selectedEnvironmentId, + releaseId: planReleaseId, + }, + } + : skipToken, + })) + const required = useMemo(() => deriveRequiredBindings(deploymentPlan.data?.slots), [deploymentPlan.data?.slots]) const [releaseNote, setReleaseNote] = useState('') - const [modelCredentials, setModelCredentials] = useState>(() => { - const model: Record = {} - required.model.forEach((provider) => { - const existing = existingDeployment?.credentials.find(c => c.kind === 'model' && c.provider === provider) - const first = credentialsByProvider.get(provider)?.[0] - model[provider] = existing?.credentialId ?? first?.id ?? '' - }) - return model - }) - const [pluginCredentials, setPluginCredentials] = useState>(() => { - const plugin: Record = {} - required.plugin.forEach((provider) => { - const existing = existingDeployment?.credentials.find(c => c.kind === 'plugin' && c.provider === provider) - const first = credentialsByProvider.get(provider)?.[0] - plugin[provider] = existing?.credentialId ?? first?.id ?? '' - }) - return plugin - }) - const [envValues, setEnvValues] = useState>(() => { - const env: Record = {} - required.envVars.forEach((v) => { - const existing = existingDeployment?.envVariables.find(e => e.key === v.key) - env[v.key] = existing?.value ?? '' - }) - return env - }) + const [modelCredentials, setModelCredentials] = useState>({}) + const [pluginCredentials, setPluginCredentials] = useState>({}) + const [envValues, setEnvValues] = useState>({}) const canDeploy = Boolean( - selectedEnvId - && required.model.every(p => modelCredentials[p]) - && required.plugin.every(p => pluginCredentials[p]) - && required.envVars.every(v => envValues[v.key]?.length), + selectedEnvironmentId + && deploymentPlan.data?.canDeploy !== false + && !deploymentPlan.isFetching + && required.model.every(item => !item.required || credentialValue(modelCredentials, item)) + && required.plugin.every(item => !item.required || credentialValue(pluginCredentials, item)) + && required.envVars.every(item => !item.required || envVarValue(envValues, item)), ) const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined @@ -231,29 +261,25 @@ const DeployForm: FC = ({ const handleDeploy = () => { if (!canDeploy) return - const credentials: CredentialBinding[] = [ - ...required.model.map(provider => ({ - provider, - kind: 'model', - credentialId: modelCredentials[provider], + const bindings: BindingsProto = { + models: required.model.map(item => ({ + slot: item.slot, + credentialId: credentialValue(modelCredentials, item), })), - ...required.plugin.map(provider => ({ - provider, - kind: 'plugin', - credentialId: pluginCredentials[provider], + plugins: required.plugin.map(item => ({ + slot: item.slot, + credentialId: credentialValue(pluginCredentials, item), })), - ] - const envVariables: EnvVariable[] = required.envVars.map(v => ({ - key: v.key, - value: envValues[v.key] ?? '', - type: v.type, - })) + envVars: required.envVars.map(item => ({ + slot: item.key, + envVarId: envVarValue(envValues, item), + })), + } onSubmit({ - environmentId: selectedEnvId, + environmentId: selectedEnvironmentId, releaseId: presetRelease?.id, releaseNote: isPromote ? undefined : releaseNote, - credentials, - envVariables, + bindings, }) } @@ -274,9 +300,9 @@ const DeployForm: FC = ({
- {presetRelease.id} + {releaseLabel(presetRelease)} · - {presetRelease.gateCommitId} + {releaseCommit(presetRelease)} {presetRelease.description && ( <> · @@ -314,11 +340,11 @@ const DeployForm: FC = ({ ? : ( ({ - value: env.id, - label: `${env.name} · ${t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${env.backend.toUpperCase()}`, + options={environments.filter(env => env.id).map(env => ({ + value: env.id!, + label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${(env.type ?? 'env').toUpperCase()}`, }))} placeholder={t('deployDrawer.selectEnv')} /> @@ -331,19 +357,18 @@ const DeployForm: FC = ({ {required.model.length > 0 && (
- {required.model.map((provider) => { - const providerCreds = credentialsByProvider.get(provider) ?? [] + {required.model.map((item) => { return ( setModelCredentials(prev => ({ ...prev, [provider]: v }))} - options={providerCreds.map(c => ({ - value: c.id, - label: `${c.name}${c.validated ? '' : t('deployDrawer.needsValidation')}`, + key={item.slot} + label={item.label} + value={credentialValue(modelCredentials, item)} + onChange={v => setModelCredentials(prev => ({ ...prev, [item.slot]: v }))} + options={item.options.map(option => ({ + value: option.id, + label: option.label, }))} - placeholder={t('deployDrawer.selectProviderKey', { provider })} + placeholder={t('deployDrawer.selectProviderKey', { provider: item.label })} /> ) })} @@ -354,16 +379,15 @@ const DeployForm: FC = ({ {required.plugin.length > 0 && (
- {required.plugin.map((provider) => { - const providerCreds = credentialsByProvider.get(provider) ?? [] + {required.plugin.map((item) => { return ( setPluginCredentials(prev => ({ ...prev, [provider]: v }))} - options={providerCreds.map(c => ({ value: c.id, label: c.name }))} - placeholder={t('deployDrawer.selectProviderCred', { provider })} + key={item.slot} + label={item.label} + value={credentialValue(pluginCredentials, item)} + onChange={v => setPluginCredentials(prev => ({ ...prev, [item.slot]: v }))} + options={item.options.map(option => ({ value: option.id, label: option.label }))} + placeholder={t('deployDrawer.selectProviderCred', { provider: item.label })} /> ) })} @@ -378,18 +402,14 @@ const DeployForm: FC = ({
{required.envVars.map(v => (
- {v.key} -
- setEnvValues(prev => ({ ...prev, [v.key]: e.target.value }))} - className={cn('min-w-0 flex-1 bg-transparent text-[13px] text-text-secondary outline-hidden placeholder:text-text-quaternary')} + {v.label} +
+ setEnvValues(prev => ({ ...prev, [v.key]: next }))} + options={v.options.map(option => ({ value: option.id, label: option.label }))} + placeholder={t('deployDrawer.defaultSelect')} /> - - {v.type} -
))} @@ -412,16 +432,15 @@ const DeployForm: FC = ({ const DeployDrawer: FC = () => { const { t } = useTranslation('deployments') const drawer = useDeploymentsStore(state => state.deployDrawer) - const environments = useDeploymentsStore(state => state.environments) - const instances = useDeploymentsStore(state => state.instances) - const releases = useDeploymentsStore(state => state.releases) - const deployments = useDeploymentsStore(state => state.deployments) + const appData = useDeploymentsStore(state => drawer.appId ? state.appData[drawer.appId] : undefined) const closeDeployDrawer = useDeploymentsStore(state => state.closeDeployDrawer) const startDeploy = useDeploymentsStore(state => state.startDeploy) const open = drawer.open - const instance = instances.find(i => i.id === drawer.instanceId) - const formKey = `${drawer.instanceId ?? 'none'}-${drawer.environmentId ?? 'any'}-${drawer.releaseId ?? 'new'}-${open ? '1' : '0'}` + const environments = appData?.candidates.environmentOptions ?? [] + const releases = appData?.candidates.releases ?? [] + const defaultReleaseId = appData?.candidates.defaultReleaseId + const formKey = `${drawer.appId ?? 'none'}-${drawer.environmentId ?? 'any'}-${drawer.releaseId ?? 'new'}-${open ? '1' : '0'}` return ( { > - {!instance + {!drawer.appId ?
{t('deployDrawer.notFound')}
: ( + onSubmit={({ environmentId, releaseId, releaseNote, bindings }) => startDeploy({ - instanceId: instance.id, + appId: drawer.appId!, environmentId, releaseId, releaseNote, - credentials, - envVariables, + bindings, })} /> )} diff --git a/web/app/components/deployments/index.tsx b/web/app/components/deployments/index.tsx index 98a54a37da..d78c46f316 100644 --- a/web/app/components/deployments/index.tsx +++ b/web/app/components/deployments/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' -import type { AppInfo, Deployment, DeployStatus, Environment, Instance } from './types' +import type { AppInfo } from './types' +import type { DeploymentAppData } from '@/service/deployments' import type { AppModeEnum } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' import { @@ -21,6 +22,7 @@ import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useRouter } from '@/next/navigation' +import { deployedRows, deploymentStatus, environmentId, environmentName, releaseLabel } from './api-utils' import CreateInstanceModal from './create-instance-modal' import DeployDrawer from './deploy-drawer' import RollbackModal from './rollback-modal' @@ -94,13 +96,11 @@ const NewInstanceCard: FC = ({ onOpen }) => { } type InstanceCardProps = { - instance: Instance app: AppInfo - deployments: Deployment[] - environments: Environment[] + appData?: DeploymentAppData } -const InstanceCard: FC = ({ instance, app, deployments, environments }) => { +const InstanceCard: FC = ({ app, appData }) => { const { t } = useTranslation('deployments') const router = useRouter() const { formatTimeFromNow } = useFormatTimeFromNow() @@ -108,7 +108,7 @@ const InstanceCard: FC = ({ instance, app, deployments, envir const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer) const deleteInstance = useDeploymentsStore(state => state.deleteInstance) - const navigateToDetail = () => router.push(`/deployments/${instance.id}/overview`) + const navigateToDetail = () => router.push(`/deployments/${app.id}/overview`) const handleMenuAction = (e: React.MouseEvent, action: () => void) => { e.stopPropagation() @@ -117,17 +117,20 @@ const InstanceCard: FC = ({ instance, app, deployments, envir action() } + const deployments = useMemo( + () => deployedRows(appData?.environmentDeployments.environmentDeployments), + [appData?.environmentDeployments.environmentDeployments], + ) const envCount = deployments.length - const failedCount = deployments.filter(d => d.status === 'deploy_failed').length - const deployingCount = deployments.filter(d => d.status === 'deploying').length - const readyCount = deployments.filter(d => d.status === 'ready').length - const envMap = useMemo(() => new Map(environments.map(env => [env.id, env])), [environments]) + 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 lastDeployedAt = useMemo(() => { if (deployments.length === 0) return null - return deployments.reduce((latest, d) => { - const t = new Date(d.createdAt).getTime() + return deployments.reduce((latest, row) => { + const t = new Date(row.instance?.lastDeployedAt || row.instance?.lastReadyAt || '').getTime() return t > latest ? t : latest }, 0) }, [deployments]) @@ -154,7 +157,7 @@ const InstanceCard: FC = ({ instance, app, deployments, envir if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0) secondaryParts.push(t('card.ready', { count: readyCount })) - const statusLabel = (status: DeployStatus) => { + const statusLabel = (status: ReturnType) => { if (status === 'deploy_failed') return t('status.deployFailed') return t(`status.${status}`) @@ -166,16 +169,16 @@ const InstanceCard: FC = ({ instance, app, deployments, envir
{t('overview.deploymentStatus')}
{deployments.map((deployment) => { - const env = envMap.get(deployment.environmentId) + const status = deploymentStatus(deployment) return ( -
+
- {env?.name ?? deployment.environmentId} + {environmentName(deployment.environment)} - {statusLabel(deployment.status)} + {statusLabel(status)} {' · '} - {deployment.activeReleaseId} + {releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
) @@ -226,7 +229,7 @@ const InstanceCard: FC = ({ instance, app, deployments, envir
-
{instance.name}
+
{app.name}
{appModeLabel} @@ -299,7 +302,7 @@ const InstanceCard: FC = ({ instance, app, deployments, envir handleMenuAction(e, () => openDeployDrawer({ instanceId: instance.id }))} + onClick={e => handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))} > {t('card.menu.deploy')} @@ -312,7 +315,7 @@ const InstanceCard: FC = ({ instance, app, deployments, envir handleMenuAction(e, () => deleteInstance(instance.id))} + onClick={e => handleMenuAction(e, () => deleteInstance(app.id))} > {t('card.menu.delete')} @@ -391,9 +394,8 @@ const EnvironmentFilter: FC = ({ value, options, onChang const DeploymentsMain: FC = () => { const { t } = useTranslation('deployments') - const instances = useDeploymentsStore(state => state.instances) - const environments = useDeploymentsStore(state => state.environments) - const deployments = useDeploymentsStore(state => state.deployments) + const sourceApps = useDeploymentsStore(state => state.sourceApps) + const appData = useDeploymentsStore(state => state.appData) const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal) const [envFilter, setEnvFilter] = useQueryState( @@ -416,15 +418,28 @@ const DeploymentsMain: FC = () => { } const { appMap } = useSourceApps() - const deploymentsByInstance = useMemo(() => { - const map = new Map() - deployments.forEach((d) => { - const list = map.get(d.instanceId) ?? [] - list.push(d) - map.set(d.instanceId, list) + const apps = useMemo( + () => sourceApps.length > 0 ? sourceApps : [...appMap.values()], + [appMap, sourceApps], + ) + const appDataList = useMemo(() => Object.values(appData), [appData]) + + const environments = useMemo(() => { + const map = new Map() + 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 - }, [deployments]) + return [...map.entries()].map(([id, name]) => ({ id, name })) + }, [appDataList]) const envIdSet = useMemo(() => new Set(environments.map(e => e.id)), [environments]) const activeFilter = envFilter === 'all' || envFilter === 'not-deployed' || envIdSet.has(envFilter) @@ -453,23 +468,21 @@ const DeploymentsMain: FC = () => { const visibleInstances = useMemo(() => { const byEnv = activeFilter === 'all' - ? instances + ? apps : activeFilter === 'not-deployed' - ? instances.filter(i => (deploymentsByInstance.get(i.id)?.length ?? 0) === 0) - : instances.filter(i => (deploymentsByInstance.get(i.id) ?? []).some(d => d.environmentId === activeFilter)) + ? 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((i) => { - const app = appMap.get(i.appId) + return byEnv.filter((app) => { return ( - i.name.toLowerCase().includes(q) - || (i.description ?? '').toLowerCase().includes(q) - || (app?.name.toLowerCase().includes(q) ?? false) + app.name.toLowerCase().includes(q) + || (app.description ?? '').toLowerCase().includes(q) ) }) - }, [instances, deploymentsByInstance, activeFilter, keywords, appMap]) + }, [apps, activeFilter, keywords, appData]) return ( <> @@ -494,17 +507,12 @@ const DeploymentsMain: FC = () => {
- {visibleInstances.map((instance) => { - const app = appMap.get(instance.appId) - if (!app) - return null + {visibleInstances.map((app) => { return ( ) })} diff --git a/web/app/components/deployments/instance-detail/access-tab.tsx b/web/app/components/deployments/instance-detail/access-tab.tsx index 131a18bd30..fa154569b1 100644 --- a/web/app/components/deployments/instance-detail/access-tab.tsx +++ b/web/app/components/deployments/instance-detail/access-tab.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC, ReactNode } from 'react' -import type { AccessPermissionKind, EnvAccessPermission, Environment } from '../types' +import type { AccessPermissionKind } from '../types' +import type { ConsoleEnvironmentSummary, DeveloperAPIKeySummary } from '@/contract/console/deployments' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -12,6 +13,13 @@ import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { + accessModeToPermissionKey, + deployedRows, + environmentName, + permissionKeyToAccessMode, + webappUrl, +} from '../api-utils' import { useDeploymentsStore } from '../store' type SectionProps = { @@ -87,22 +95,18 @@ const CopyPill: FC = ({ label, value, prefix, className }) => { } type ApiKeyRowProps = { - label: string - envName: string - value: string + apiKey: DeveloperAPIKeySummary onRevoke: () => void } -const ApiKeyRow: FC = ({ label, envName, value, onRevoke }) => { +const ApiKeyRow: FC = ({ apiKey, onRevoke }) => { const { t } = useTranslation('deployments') - const [visible, setVisible] = useState(false) const [copied, setCopied] = useState(false) - - const displayValue = visible ? value : `${value.slice(0, 6)}${'•'.repeat(14)}${value.slice(-4)}` + const displayValue = apiKey.maskedPrefix || apiKey.id || '—' const handleCopy = async () => { try { - await navigator.clipboard.writeText(value) + await navigator.clipboard.writeText(displayValue) setCopied(true) toast.success(t('access.copyToast')) window.setTimeout(() => setCopied(false), 1500) @@ -115,23 +119,15 @@ const ApiKeyRow: FC = ({ label, envName, value, onRevoke }) => { return (
- {label} + {apiKey.name || apiKey.id} - {t('access.api.envPrefix', { env: envName })} + {t('access.api.envPrefix', { env: apiKey.environmentName || apiKey.environmentId })}
{displayValue}
- +
+ +
+ )} + {apiKeys.length === 0 ? (
{deployedEnvs.length === 0 @@ -542,15 +566,14 @@ const AccessTab: FC = ({ instanceId }) => { ) : (
- {instanceKeys.map((k) => { - const env = envMap.get(k.environmentId) + {apiKeys.map((apiKey) => { + if (!apiKey.id || !apiKey.environmentId) + return null return ( revokeApiKey(k.id)} + key={apiKey.id} + apiKey={apiKey} + onRevoke={() => revokeApiKey(appId, apiKey.environmentId!, apiKey.id!)} /> ) })} diff --git a/web/app/components/deployments/instance-detail/deploy-tab.tsx b/web/app/components/deployments/instance-detail/deploy-tab.tsx index 1adee73703..f3bc7adbf8 100644 --- a/web/app/components/deployments/instance-detail/deploy-tab.tsx +++ b/web/app/components/deployments/instance-detail/deploy-tab.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { Deployment, Environment, Release } from '../types' +import type { EnvironmentDeploymentRow } from '@/contract/console/deployments' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -12,7 +12,21 @@ import { import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { mockCredentials } from '../mock-data' +import { + activeRelease, + deployedRows, + deploymentId, + deploymentStatus, + environmentBackend, + environmentHealth, + environmentId, + environmentMode, + environmentName, + formatDate, + releaseCommit, + releaseLabel, + targetRelease, +} from '../api-utils' import { HealthBadge, ModeBadge } from '../status-badge' import { useDeploymentsStore } from '../store' @@ -48,103 +62,85 @@ const InfoRow: FC = ({ label, value, mono, suffix }) => ( ) type DeploymentPanelProps = { - deployment: Deployment - env: Environment - release?: Release - targetRelease?: Release - failedRelease?: Release + row: EnvironmentDeploymentRow } -const DeploymentPanel: FC = ({ deployment, env, release, targetRelease, failedRelease }) => { +const DeploymentPanel: FC = ({ row }) => { const { t } = useTranslation('deployments') - const credentialMap = useMemo( - () => new Map(mockCredentials.map(c => [c.id, c])), - [], - ) - - const modelCreds = deployment.credentials.filter(c => c.kind === 'model') - const pluginCreds = deployment.credentials.filter(c => c.kind === 'plugin') + const observed = activeRelease(row) + const pending = targetRelease(row) + const env = row.environment + const observedBindings = row.observedRuntime?.bindings + const pendingBindings = row.pendingDeployment?.bindings + const credentials = [...observedBindings?.credentials ?? [], ...pendingBindings?.credentials ?? []] + const envVars = [...observedBindings?.envVars ?? [], ...pendingBindings?.envVars ?? []] return (
- {env.name} + {environmentName(env)} {' · '} - {deployment.activeReleaseId} + {releaseLabel(observed || pending)} - - + +
- - - - + + + + - - - - {targetRelease && ( - + + + + {pending && ( + )} - {failedRelease && ( - + {row.instance?.lastError?.releaseId && ( + )} - - + + - {modelCreds.length > 0 && ( + {credentials.length > 0 && ( - {modelCreds.map(c => ( + {credentials.map(c => ( ))} )} - {pluginCreds.length > 0 && ( - - {pluginCreds.map(c => ( - - ))} - - )} - - {deployment.envVariables.length > 0 && ( + {envVars.length > 0 && ( - {deployment.envVariables.map(v => ( + {envVars.map(v => ( ))} )}
- {deployment.status === 'deploy_failed' && deployment.errorMessage && ( + {row.instance?.lastError?.message && (
- {deployment.errorMessage} + {row.instance.lastError.message}
)}
@@ -152,22 +148,23 @@ const DeploymentPanel: FC = ({ deployment, env, release, t } type DeploymentStatusSummaryProps = { - deployment: Deployment + row: EnvironmentDeploymentRow } -const DeploymentStatusSummary: FC = ({ deployment }) => { +const DeploymentStatusSummary: FC = ({ row }) => { const { t } = useTranslation('deployments') + const status = deploymentStatus(row) - if (deployment.status === 'deploying') { + if (status === 'deploying') { return ( - {t('deployTab.status.deployingRelease', { release: deployment.targetReleaseId ?? deployment.activeReleaseId })} + {t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease(row) || activeRelease(row)) })} ) } - if (deployment.status === 'deploy_failed') { + if (status === 'deploy_failed') { return ( @@ -184,106 +181,26 @@ const DeploymentStatusSummary: FC = ({ deployment ) } -type RowPrimaryActionProps = { - deployment: Deployment - onPromote: () => void - onViewProgress: () => void - onViewLogs: () => void -} - -const RowPrimaryAction: FC = ({ deployment, onPromote, onViewProgress, onViewLogs }) => { - const { t } = useTranslation('deployments') - - if (deployment.status === 'deploying') { - return ( - - ) - } - - if (deployment.status === 'deploy_failed') { - return ( - - ) - } - - return ( - - ) -} - -type DeploymentMenuProps = { - deployment: Deployment - onUndeploy: () => void -} - -const DeploymentMenu: FC = ({ deployment, onUndeploy }) => { - const { t } = useTranslation('deployments') - const [menuOpen, setMenuOpen] = useState(false) - const itemLabel = deployment.status === 'deploying' - ? t('deployTab.cancelDeployment') - : t('deployTab.undeploy') - - return ( - - - - - {menuOpen && ( - - { - setMenuOpen(false) - onUndeploy() - }} - > - - {itemLabel} - - - - )} - - ) -} - type DeployTabProps = { instanceId: string } -const DeployTab: FC = ({ instanceId }) => { +const DeployTab: FC = ({ instanceId: appId }) => { const { t } = useTranslation('deployments') - const environments = useDeploymentsStore(state => state.environments) - const deployments = useDeploymentsStore(state => state.deployments) + const appData = useDeploymentsStore(state => state.appData[appId]) const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer) const undeployDeployment = useDeploymentsStore(state => state.undeployDeployment) - const releases = useDeploymentsStore(state => state.releases) - const instanceDeployments = useMemo( - () => deployments.filter(d => d.instanceId === instanceId), - [deployments, instanceId], + const rows = useMemo( + () => deployedRows(appData?.environmentDeployments.environmentDeployments), + [appData?.environmentDeployments.environmentDeployments], ) - const envMap = useMemo( - () => new Map(environments.map(env => [env.id, env])), - [environments], - ) - - const [expanded, setExpanded] = useState(() => instanceDeployments[0]?.id ?? null) - + const deployedEnvIds = new Set(rows.map(row => environmentId(row.environment))) + const availableEnvs = appData?.candidates.environmentOptions?.filter(env => env.id && !deployedEnvIds.has(env.id)) ?? [] + const [expanded, setExpanded] = useState(() => rows[0] ? environmentId(rows[0].environment) : null) const toggle = (id: string) => setExpanded(prev => (prev === id ? null : id)) - const [deployMenuOpen, setDeployMenuOpen] = useState(false) - const availableEnvs = environments.filter(env => !instanceDeployments.some(d => d.environmentId === env.id)) return (
@@ -293,7 +210,7 @@ const DeployTab: FC = ({ instanceId }) => { {' '} ( - {instanceDeployments.length} + {rows.length} )
@@ -315,7 +232,7 @@ const DeployTab: FC = ({ instanceId }) => { className="gap-2 px-3" onClick={() => { setDeployMenuOpen(false) - openDeployDrawer({ instanceId }) + openDeployDrawer({ appId }) }} > {t('deployTab.deployToNewEnv')} @@ -329,11 +246,11 @@ const DeployTab: FC = ({ instanceId }) => { className="gap-2 px-3" onClick={() => { setDeployMenuOpen(false) - openDeployDrawer({ instanceId, environmentId: env.id }) + openDeployDrawer({ appId, environmentId: env.id }) }} > - {t('deployTab.deployToEnv', { name: env.name })} + {t('deployTab.deployToEnv', { name: environmentName(env) })} ))} @@ -344,7 +261,7 @@ const DeployTab: FC = ({ instanceId }) => {
- {instanceDeployments.length === 0 + {rows.length === 0 ? (
{t('deployTab.empty')} @@ -362,26 +279,34 @@ const DeployTab: FC = ({ instanceId }) => {
{t('deployTab.col.status')}
- {instanceDeployments.map((deployment) => { - const env = envMap.get(deployment.environmentId) - if (!env) - return null - const isExpanded = expanded === deployment.id - const release = releases.find(r => r.id === deployment.activeReleaseId) - const targetRelease = deployment.targetReleaseId ? releases.find(r => r.id === deployment.targetReleaseId) : undefined - const failedRelease = deployment.failedReleaseId ? releases.find(r => r.id === deployment.failedReleaseId) : undefined + {rows.map((row) => { + const envId = environmentId(row.environment) + const isExpanded = expanded === envId + const status = deploymentStatus(row) + const release = activeRelease(row) || targetRelease(row) const actions = (
e.stopPropagation()}> - openDeployDrawer({ instanceId, environmentId: deployment.environmentId })} - onViewProgress={() => setExpanded(deployment.id)} - onViewLogs={() => setExpanded(deployment.id)} - /> - undeployDeployment(deployment.id)} - /> + + + + + + + undeployDeployment(appId, envId, deploymentId(row), status === 'deploying')} + > + + {status === 'deploying' ? t('deployTab.cancelDeployment') : t('deployTab.undeploy')} + + + +
) const chevron = ( @@ -393,10 +318,10 @@ const DeployTab: FC = ({ instanceId }) => { /> ) return ( -
+
- {isExpanded && ( - - )} + {isExpanded && }
) })} diff --git a/web/app/components/deployments/instance-detail/index.tsx b/web/app/components/deployments/instance-detail/index.tsx index b679417393..159d0bfe12 100644 --- a/web/app/components/deployments/instance-detail/index.tsx +++ b/web/app/components/deployments/instance-detail/index.tsx @@ -19,6 +19,7 @@ import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSelectedLayoutSegment } from '@/next/navigation' +import { deployedRows, deploymentStatus } from '../api-utils' import DeployDrawer from '../deploy-drawer' import RollbackModal from '../rollback-modal' import { useDeploymentsStore } from '../store' @@ -219,19 +220,18 @@ const InstanceDetail: FC = ({ instanceId, children }) => { const selectedSegment = useSelectedLayoutSegment() const selectedTab = selectedSegment ?? undefined const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview' - const instances = useDeploymentsStore(state => state.instances) - const deployments = useDeploymentsStore(state => state.deployments) + const sourceApps = useDeploymentsStore(state => state.sourceApps) + const appData = useDeploymentsStore(state => state.appData) const { appMap, isLoading: isLoadingApps } = useSourceApps() useDocumentTitle(t('documentTitle.detail')) - const instance = useMemo(() => instances.find(i => i.id === instanceId), [instances, instanceId]) const app = useMemo( - () => instance ? appMap.get(instance.appId) : undefined, - [instance, appMap], + () => sourceApps.find(item => item.id === instanceId) ?? appMap.get(instanceId), + [sourceApps, instanceId, appMap], ) - const instanceDeployments = useMemo( - () => instance ? deployments.filter(d => d.instanceId === instance.id) : [], - [deployments, instance], + const appDeployments = useMemo( + () => deployedRows(appData[instanceId]?.environmentDeployments.environmentDeployments), + [appData, instanceId], ) if (isLoadingApps && !app) { @@ -242,7 +242,7 @@ const InstanceDetail: FC = ({ instanceId, children }) => { ) } - if (!instance) { + if (!app) { return (
{t('detail.notFound')}
@@ -254,8 +254,8 @@ const InstanceDetail: FC = ({ instanceId, children }) => { ) } - const deployingCount = instanceDeployments.filter(d => d.status === 'deploying').length - const failedCount = instanceDeployments.filter(d => d.status === 'deploy_failed').length + const deployingCount = appDeployments.filter(row => deploymentStatus(row) === 'deploying').length + const failedCount = appDeployments.filter(row => deploymentStatus(row) === 'deploy_failed').length const appModeLabel = app ? getAppModeLabel(app.mode, tCommon) : t('detail.sourceAppDeleted') return ( @@ -263,8 +263,8 @@ const InstanceDetail: FC = ({ instanceId, children }) => {
@@ -279,7 +279,7 @@ const InstanceDetail: FC = ({ instanceId, children }) => {
{t(`tabs.${activeTab}.description`)}
- {t('detail.envCount', { count: instanceDeployments.length })} + {t('detail.envCount', { count: appDeployments.length })} {deployingCount > 0 && ( <> · diff --git a/web/app/components/deployments/instance-detail/overview-tab.tsx b/web/app/components/deployments/instance-detail/overview-tab.tsx index 80442924ad..d02108ebb6 100644 --- a/web/app/components/deployments/instance-detail/overview-tab.tsx +++ b/web/app/components/deployments/instance-detail/overview-tab.tsx @@ -1,14 +1,12 @@ 'use client' import type { FC } from 'react' -import type { AppInfo } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' -import { RiArrowRightUpLine, RiErrorWarningLine, RiExchangeLine, RiRocketLine } from '@remixicon/react' import * as React from 'react' -import { useMemo, useState } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { AppPicker } from '../create-instance-modal' +import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels' +import { deployedRows, deploymentStatus, environmentName, formatDate, releaseLabel, webappUrl } from '../api-utils' import { StatusBadge } from '../status-badge' import { useDeploymentsStore } from '../store' import { useSourceApps } from '../use-source-apps' @@ -47,83 +45,6 @@ const InfoRow: FC = ({ label, value, mono }) => (
) -type SwitchSourceAppDialogProps = { - open: boolean - instanceId: string - currentAppId: string - apps: AppInfo[] - isLoading: boolean - onClose: () => void -} - -const SwitchSourceAppDialog: FC = ({ - open, - instanceId, - currentAppId, - apps, - isLoading, - onClose, -}) => { - const { t } = useTranslation('deployments') - const switchSourceApp = useDeploymentsStore(state => state.switchSourceApp) - const [selectedAppId, setSelectedAppId] = useState('') - - const currentAppExists = apps.some(app => app.id === currentAppId) - const pickerValue = selectedAppId || (currentAppExists ? currentAppId : '') - - const canSwitch = Boolean(pickerValue && pickerValue !== currentAppId) - - const handleSwitch = () => { - if (!canSwitch) - return - switchSourceApp(instanceId, pickerValue) - onClose() - } - - return ( - !next && onClose()}> - - -
-
- - {t('overview.switchSourceApp')} - - - {t('overview.switchSourceAppDescription')} - -
- -
- - -
- -
- {t('overview.switchSourceAppHint')} -
- -
- - -
-
-
-
- ) -} - type AccessOverviewRowProps = { label: string enabled: boolean @@ -157,221 +78,108 @@ const AccessOverviewRow: FC = ({ label, enabled, hint }) const OverviewTab: FC = ({ instanceId, onSwitchTab }) => { const { t } = useTranslation('deployments') - const instances = useDeploymentsStore(state => state.instances) - const deployments = useDeploymentsStore(state => state.deployments) - const environments = useDeploymentsStore(state => state.environments) - const access = useDeploymentsStore(state => state.access) - const apiKeys = useDeploymentsStore(state => state.apiKeys) + const { t: tCommon } = useTranslation() + const appData = useDeploymentsStore(state => state.appData[instanceId]) const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer) - const [switchSourceOpen, setSwitchSourceOpen] = useState(false) + const { appMap } = useSourceApps() + const app = appMap.get(instanceId) - const { apps, appMap, isLoading: isLoadingApps } = useSourceApps() - const instance = instances.find(i => i.id === instanceId) - const app = instance ? appMap.get(instance.appId) : undefined - const sourceAppMissing = Boolean(instance && !isLoadingApps && !app) - - const instanceDeployments = useMemo( - () => deployments.filter(d => d.instanceId === instanceId), - [deployments, instanceId], - ) - const instanceAccess = access.find(a => a.instanceId === instanceId) - const instanceKeys = useMemo( - () => apiKeys.filter(k => k.instanceId === instanceId), - [apiKeys, instanceId], + const deployments = useMemo( + () => deployedRows(appData?.environmentDeployments.environmentDeployments), + [appData?.environmentDeployments.environmentDeployments], ) - const envMap = useMemo( - () => new Map(environments.map(env => [env.id, env])), - [environments], - ) - - if (!instance) + if (!app) return null - const runAccessEnabled = instanceAccess?.enabled.runAccess ?? false - const apiAccessEnabled = instanceAccess?.enabled.api ?? false - const endUserAccessEntries: AccessOverviewRowProps[] = [ - { - label: t('overview.webapp'), - enabled: runAccessEnabled && Boolean(instanceAccess?.webappUrl), - hint: instanceAccess?.webappUrl ?? t('overview.notConfigured'), - }, - { - label: t('overview.cli'), - enabled: runAccessEnabled && Boolean(instanceAccess?.mcpUrl), - hint: instanceAccess?.mcpUrl ?? t('overview.notConfigured'), - }, - ] - const developerAccessEntries: AccessOverviewRowProps[] = [ - { - label: t('overview.api'), - enabled: apiAccessEnabled, - hint: apiAccessEnabled - ? t('overview.apiKeysCount', { count: instanceKeys.length }) - : t('overview.notConfigured'), - }, - ] - - const appModeLabel = app - ? t(`appMode.${app.mode}`, { defaultValue: app.mode }) - : t('overview.sourceAppUnavailable') + const appModeLabel = getAppModeLabel(app.mode, tCommon) + const webappRow = appData?.accessConfig.webapp?.rows?.find(row => row.url) + const webappAccessUrl = webappUrl(webappRow?.url) + const cliUrl = appData?.accessConfig.cli?.url + const apiKeysCount = appData?.accessConfig.developerApi?.apiKeys?.length ?? 0 return ( - <> -
- {sourceAppMissing && ( -
- -
-
- {t('overview.sourceAppDeletedTitle')} -
-
- {t('overview.sourceAppDeletedDescription')} -
- -
-
+
+
+
+ + + + +
+
+ +
onSwitchTab?.('deploy')}> + {t('overview.viewDeployments')} + + )} - -
-
- - - - - {app?.name ?? t('overview.sourceAppDeletedValue')} - - -
- )} - /> - - - -
- - -
onSwitchTab('deploy')} - className="flex items-center gap-1 system-xs-medium text-text-accent hover:underline" - > - {t('overview.viewDeployments')} - - - )} - > - {instanceDeployments.length === 0 - ? ( -
-
- {t('overview.notDeployedYet')} -
- -
- ) - : ( -
- {instanceDeployments.map((deployment) => { - const env = envMap.get(deployment.environmentId) - if (!env) - return null - return ( -
-
-
- {env.name} - - {t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} - {' · '} - {env.backend.toUpperCase()} - -
-
-
- - {deployment.activeReleaseId} - - -
+ > + {deployments.length === 0 + ? ( +
+ +
{t('overview.notDeployedYet')}
+ +
+ ) + : ( +
+ {deployments.map((row) => { + const status = deploymentStatus(row) + return ( +
+
+ {environmentName(row.environment)} + + {releaseLabel(row.observedRuntime?.release || row.pendingDeployment?.release)} + {' · '} + {formatDate(row.instance?.lastDeployedAt || row.instance?.lastReadyAt)} +
- ) - })} -
- )} -
- -
onSwitchTab('access')} - className="flex items-center gap-1 system-xs-medium text-text-accent hover:underline" - > - {t('overview.configureAccess')} - - - )} - > -
-
-
{t('overview.endUserAccess')}
-
- {endUserAccessEntries.map(entry => ( - - ))} + +
+ ) + })}
-
-
-
{t('overview.developerApi')}
-
- {developerAccessEntries.map(entry => ( - - ))} -
-
-
- -
+ )} + - setSwitchSourceOpen(false)} - /> - +
onSwitchTab?.('access')}> + {t('overview.configureAccess')} + + + )} + > +
+ + + +
+
+
) } diff --git a/web/app/components/deployments/instance-detail/settings-tab.tsx b/web/app/components/deployments/instance-detail/settings-tab.tsx index ccbeb57a02..b2c43e321d 100644 --- a/web/app/components/deployments/instance-detail/settings-tab.tsx +++ b/web/app/components/deployments/instance-detail/settings-tab.tsx @@ -1,38 +1,40 @@ 'use client' import type { FC } from 'react' -import type { Instance } from '../types' +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' type SettingsTabProps = { instanceId: string } type SettingsFormProps = { - instance: Instance + app: AppInfo hasDeployments: boolean } -const SettingsForm: FC = ({ instance, hasDeployments }) => { +const SettingsForm: FC = ({ 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(instance.name) - const [description, setDescription] = useState(instance.description ?? '') + const [name, setName] = useState(app.name) + const [description, setDescription] = useState(app.description ?? '') - const dirty = name !== instance.name || description !== (instance.description ?? '') + const dirty = name !== app.name || description !== (app.description ?? '') const handleSave = () => { if (!name.trim()) return - updateInstance(instance.id, { + updateInstance(app.id, { name: name.trim(), description: description.trim() || undefined, }) @@ -40,8 +42,8 @@ const SettingsForm: FC = ({ instance, hasDeployments }) => { } const handleReset = () => { - setName(instance.name) - setDescription(instance.description ?? '') + setName(app.name) + setDescription(app.description ?? '') } const handleDelete = () => { @@ -49,7 +51,7 @@ const SettingsForm: FC = ({ instance, hasDeployments }) => { toast.error(t('settings.undeployFirst')) return } - deleteInstance(instance.id) + deleteInstance(app.id) router.push('/deployments') } @@ -116,18 +118,18 @@ const SettingsForm: FC = ({ instance, hasDeployments }) => { } const SettingsTab: FC = ({ instanceId }) => { - const instances = useDeploymentsStore(state => state.instances) - const deployments = useDeploymentsStore(state => state.deployments) + const sourceApps = useDeploymentsStore(state => state.sourceApps) + const appData = useDeploymentsStore(state => state.appData[instanceId]) + const { appMap } = useSourceApps() + const app = sourceApps.find(item => item.id === instanceId) ?? appMap.get(instanceId) - const instance = instances.find(i => i.id === instanceId) - - if (!instance) + if (!app) return null - const hasDeployments = deployments.some(d => d.instanceId === instanceId) - const formKey = `${instance.id}-${instance.name}-${instance.description ?? ''}` + const hasDeployments = deployedRows(appData?.environmentDeployments.environmentDeployments).length > 0 + const formKey = `${app.id}-${app.name}-${app.description ?? ''}` - return + return } export default SettingsTab diff --git a/web/app/components/deployments/instance-detail/versions-tab.tsx b/web/app/components/deployments/instance-detail/versions-tab.tsx index 3e2ea77ed2..e71221387a 100644 --- a/web/app/components/deployments/instance-detail/versions-tab.tsx +++ b/web/app/components/deployments/instance-detail/versions-tab.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { DeployedToSummary, ReleaseHistoryRow } from '@/contract/console/deployments' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -10,6 +11,18 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { + activeRelease, + deployedRows, + deploymentId, + deploymentStatus, + environmentId, + environmentName, + formatDate, + releaseCommit, + releaseLabel, + targetRelease, +} from '../api-utils' import { useDeploymentsStore } from '../store' const GRID_TEMPLATE = 'grid-cols-[0.9fr_1fr_0.8fr_1.5fr_auto]' @@ -28,20 +41,47 @@ const RELEASE_DEPLOYMENT_STYLES: Record = { failed: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700', } -type DeployReleaseMenuProps = { - releaseId: string - instanceId: string +function releaseDeploymentState(status?: string): ReleaseDeploymentState { + const normalized = status?.toLowerCase() ?? '' + if (normalized.includes('deploying') || normalized.includes('pending')) + return 'deploying' + if (normalized.includes('fail') || normalized.includes('error')) + return 'failed' + return 'active' } -const DeployReleaseMenu: FC = ({ releaseId, instanceId }) => { +function fromDeployedTo(item: DeployedToSummary): ReleaseDeployment | undefined { + if (!item.environmentId) + return undefined + + return { + environmentId: item.environmentId, + environmentName: item.environmentName || item.environmentId, + state: releaseDeploymentState(item.instanceStatus), + } +} + +function dedupeReleaseDeployments(items: ReleaseDeployment[]) { + return items.filter((item, index) => { + const key = `${item.environmentId}-${item.state}` + return items.findIndex(candidate => `${candidate.environmentId}-${candidate.state}` === key) === index + }) +} + +type DeployReleaseMenuProps = { + appId: string + releaseId: string +} + +const DeployReleaseMenu: FC = ({ appId, releaseId }) => { const { t } = useTranslation('deployments') - const environments = useDeploymentsStore(state => state.environments) - const deployments = useDeploymentsStore(state => state.deployments) + const appData = useDeploymentsStore(state => state.appData[appId]) const openDeployDrawer = useDeploymentsStore(state => state.openDeployDrawer) const openRollbackModal = useDeploymentsStore(state => state.openRollbackModal) const [open, setOpen] = useState(false) - const instanceDeployments = deployments.filter(d => d.instanceId === instanceId) + const environments = appData?.candidates.environmentOptions?.filter(env => env.id) ?? [] + const deploymentRows = deployedRows(appData?.environmentDeployments.environmentDeployments) return ( @@ -58,36 +98,40 @@ const DeployReleaseMenu: FC = ({ releaseId, instanceId } {open && ( {environments.map((env) => { - const deployment = instanceDeployments.find(d => d.environmentId === env.id) - const isCurrent = deployment?.activeReleaseId === releaseId - const isEnvironmentDeploying = deployment?.status === 'deploying' + const envId = env.id! + const row = deploymentRows.find(item => environmentId(item.environment) === envId) + const isCurrent = activeRelease(row)?.id === releaseId + const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false + const disabled = Boolean(env.disabled || isCurrent || isEnvironmentDeploying) return ( { setOpen(false) - if (isCurrent || isEnvironmentDeploying) + if (disabled) return - if (deployment) { + if (row) { openRollbackModal({ - deploymentId: deployment.id, + appId, + environmentId: envId, + deploymentId: deploymentId(row), targetReleaseId: releaseId, }) return } - openDeployDrawer({ instanceId, environmentId: env.id, releaseId }) + openDeployDrawer({ appId, environmentId: envId, releaseId }) }} > {isEnvironmentDeploying - ? t('versions.deployingTo', { name: env.name }) + ? t('versions.deployingTo', { name: environmentName(env) }) : isCurrent - ? t('versions.currentOn', { name: env.name }) - : deployment - ? t('versions.promoteTo', { name: env.name }) - : t('versions.deployTo', { name: env.name })} + ? t('versions.currentOn', { name: environmentName(env) }) + : row + ? t('versions.promoteTo', { name: environmentName(env) }) + : t('versions.deployTo', { name: environmentName(env) })} ) @@ -98,46 +142,6 @@ const DeployReleaseMenu: FC = ({ releaseId, instanceId } ) } -type ReleaseMoreMenuProps = { - previewVisible: boolean - onTogglePreview: () => void -} - -const ReleaseMoreMenu: FC = ({ previewVisible, onTogglePreview }) => { - const { t } = useTranslation('deployments') - const [open, setOpen] = useState(false) - - return ( - - - - - {open && ( - - { - setOpen(false) - onTogglePreview() - }} - > - - - {previewVisible ? t('versions.hideYaml') : t('versions.viewYaml')} - - - - )} - - ) -} - const DeployedToBadge: FC<{ item: ReleaseDeployment }> = ({ item }) => { const { t } = useTranslation('deployments') const statusLabel = t(`versions.deployedStatus.${item.state}`) @@ -174,71 +178,55 @@ type VersionsTabProps = { instanceId: string } -const VersionsTab: FC = ({ instanceId }) => { +const VersionsTab: FC = ({ instanceId: appId }) => { const { t } = useTranslation('deployments') - const instances = useDeploymentsStore(state => state.instances) - const releases = useDeploymentsStore(state => state.releases) - const deployments = useDeploymentsStore(state => state.deployments) - const environments = useDeploymentsStore(state => state.environments) - - const instance = instances.find(i => i.id === instanceId) - - const instanceDeployments = useMemo( - () => deployments.filter(d => d.instanceId === instanceId), - [deployments, instanceId], + const appData = useDeploymentsStore(state => state.appData[appId]) + const releaseRows = useMemo( + () => appData?.releaseHistory.data?.filter(row => row.release?.id) ?? [], + [appData?.releaseHistory.data], + ) + const deploymentRows = useMemo( + () => deployedRows(appData?.environmentDeployments.environmentDeployments), + [appData?.environmentDeployments.environmentDeployments], ) - const appReleases = useMemo(() => { - if (!instance) + const getReleaseDeployments = (row: ReleaseHistoryRow) => { + const releaseId = row.release?.id + if (!releaseId) return [] - const deployedReleaseIds = new Set() - instanceDeployments.forEach((deployment) => { - deployedReleaseIds.add(deployment.activeReleaseId) - if (deployment.targetReleaseId) - deployedReleaseIds.add(deployment.targetReleaseId) - if (deployment.failedReleaseId) - deployedReleaseIds.add(deployment.failedReleaseId) - }) - return releases.filter(r => r.appId === instance.appId || deployedReleaseIds.has(r.id)) - }, [releases, instance, instanceDeployments]) - const [previewId, setPreviewId] = useState(null) - - if (!instance) - return null - - const envMap = new Map(environments.map(env => [env.id, env])) - - const getReleaseDeployments = (releaseId: string) => { - return instanceDeployments.flatMap((deployment) => { - const env = envMap.get(deployment.environmentId) - if (!env) + const historyItems = row.deployedTo?.map(fromDeployedTo).filter((item): item is ReleaseDeployment => !!item) ?? [] + const runtimeItems = deploymentRows.flatMap((deployment) => { + const envId = environmentId(deployment.environment) + if (!envId) return [] const items: ReleaseDeployment[] = [] - if (deployment.activeReleaseId === releaseId) { + if (activeRelease(deployment)?.id === releaseId) { items.push({ - environmentId: deployment.environmentId, - environmentName: env.name, + environmentId: envId, + environmentName: environmentName(deployment.environment), state: 'active', }) } - if (deployment.status === 'deploying' && deployment.targetReleaseId === releaseId) { + if (targetRelease(deployment)?.id === releaseId) { items.push({ - environmentId: deployment.environmentId, - environmentName: env.name, + environmentId: envId, + environmentName: environmentName(deployment.environment), state: 'deploying', }) } - if (deployment.status === 'deploy_failed' && deployment.failedReleaseId === releaseId) { + if (deployment.instance?.lastError?.releaseId === releaseId) { items.push({ - environmentId: deployment.environmentId, - environmentName: env.name, + environmentId: envId, + environmentName: environmentName(deployment.environment), state: 'failed', }) } return items }) + + return dedupeReleaseDeployments([...historyItems, ...runtimeItems]) } return ( @@ -249,13 +237,13 @@ const VersionsTab: FC = ({ instanceId }) => { {' '} ( - {appReleases.length} + {releaseRows.length} )
- {appReleases.length === 0 + {releaseRows.length === 0 ? (
{t('versions.empty')} @@ -275,9 +263,9 @@ const VersionsTab: FC = ({ instanceId }) => {
{t('versions.col.action')}
- {appReleases.map((release) => { - const releaseDeployments = getReleaseDeployments(release.id) - const isPreview = previewId === release.id + {releaseRows.map((row) => { + const release = row.release! + const releaseDeployments = getReleaseDeployments(row) return (
@@ -290,26 +278,22 @@ const VersionsTab: FC = ({ instanceId }) => { - {release.id} + {releaseLabel(release)} )} /> - {t('versions.commitTooltip', { commit: release.gateCommitId })} + {t('versions.commitTooltip', { commit: releaseCommit(release) })}
- {release.createdAt} + {formatDate(release.createdAt)} · - {release.operator} + {row.createdBy?.displayName ?? '—'}
- - setPreviewId(prev => (prev === release.id ? null : release.id))} - /> +
@@ -338,17 +322,17 @@ const VersionsTab: FC = ({ instanceId }) => { - {release.id} + {releaseLabel(release)} )} /> - {t('versions.commitTooltip', { commit: release.gateCommitId })} + {t('versions.commitTooltip', { commit: releaseCommit(release) })}
-
{release.createdAt}
-
{release.operator}
+
{formatDate(release.createdAt)}
+
{row.createdBy?.displayName ?? '—'}
{releaseDeployments.length === 0 ? @@ -360,20 +344,9 @@ const VersionsTab: FC = ({ instanceId }) => { ))}
- - setPreviewId(prev => (prev === release.id ? null : release.id))} - /> +
- {isPreview && ( -
-
-                          {release.yaml}
-                        
-
- )}
) })} diff --git a/web/app/components/deployments/mock-data.ts b/web/app/components/deployments/mock-data.ts deleted file mode 100644 index 82bf445151..0000000000 --- a/web/app/components/deployments/mock-data.ts +++ /dev/null @@ -1,407 +0,0 @@ -import type { ApiKey, Credential, Deployment, Environment, Instance, InstanceAccess, Member, MemberGroup, Release } from './types' - -export const mockEnvironments: Environment[] = [ - { - id: 'env-default', - name: 'default', - namespace: 'default', - description: 'Default shared environment, provisioned by Helm', - mode: 'shared', - backend: 'k8s', - health: 'ready', - createdAt: '2026-03-02 10:11', - }, - { - id: 'env-prod-isolated', - name: 'prod-isolated', - namespace: 'payments', - description: 'Isolated production environment for the Payments team', - mode: 'isolated', - backend: 'k8s', - health: 'ready', - createdAt: '2026-03-14 19:22', - }, - { - id: 'env-qa-host', - name: 'qa-host', - namespace: '—', - description: 'Staging host pool used for smoke testing', - mode: 'shared', - backend: 'host', - health: 'degraded', - createdAt: '2026-02-08 16:40', - }, -] - -export const MOCK_APP_ID_SLOTS = [ - 'app-customer-support', - 'app-payments-workflow', - 'app-marketing-copy', - 'app-onboarding-draft', -] as const - -export const mockCredentials: Credential[] = [ - { - id: 'cred-openai-prod', - name: 'openai-prod', - provider: 'OpenAI', - kind: 'model', - scope: 'Workspace scoped', - validated: true, - }, - { - id: 'cred-openai-test', - name: 'openai-test', - provider: 'OpenAI', - kind: 'model', - scope: 'Workspace scoped', - validated: false, - }, - { - id: 'cred-deepseek-prod', - name: 'deepseek-prod', - provider: 'DeepSeek', - kind: 'model', - scope: 'Workspace scoped', - validated: true, - }, - { - id: 'cred-anthropic-prod', - name: 'anthropic-prod', - provider: 'Anthropic', - kind: 'model', - scope: 'Workspace scoped', - validated: true, - }, - { - id: 'cred-gmail-key001', - name: 'gmail-key001', - provider: 'Gmail', - kind: 'plugin', - scope: 'Workspace scoped', - validated: true, - }, - { - id: 'cred-notion-key001', - name: 'notion-key001', - provider: 'Notion', - kind: 'plugin', - scope: 'Workspace scoped', - validated: true, - }, -] - -const sampleYaml = (appName: string, releaseId: string) => `# Release: ${releaseId} -app: - name: ${appName} - mode: advanced-chat - model: - provider: openai - name: gpt-4o - parameters: - temperature: 0.2 - top_p: 0.95 - prompt: | - You are a helpful assistant for ${appName}. - Follow company guidelines strictly. - tools: - - code-interpreter - - knowledge-retrieval -runner: - replicas: 3 - maxTokens: 16384 - timeoutSeconds: 120 -observability: - logLevel: info - tracing: true -` - -export const mockReleases: Release[] = [ - { - id: 'R-043', - appId: 'app-payments-workflow', - gateCommitId: 'a3716d90', - operator: 'byron', - createdAt: '2026-04-15 19:08', - description: 'current draft deploy', - yaml: sampleYaml('Payments Workflow', 'R-043'), - }, - { - id: 'R-042', - appId: 'app-customer-support', - gateCommitId: '9f23a1d2', - operator: 'byron', - createdAt: '2026-04-15 18:32', - description: 'stable release', - yaml: sampleYaml('Customer Support Bot', 'R-042'), - }, - { - id: 'R-041', - appId: 'app-marketing-copy', - gateCommitId: '7db24e51', - operator: 'alice', - createdAt: '2026-04-13 15:10', - description: 'deploy failed on qa', - yaml: sampleYaml('Marketing Copy Generator', 'R-041'), - }, - { - id: 'R-040', - appId: 'app-marketing-copy', - gateCommitId: '58c10aee', - operator: 'alice', - createdAt: '2026-04-12 09:24', - description: 'last stable qa release', - yaml: sampleYaml('Marketing Copy Generator', 'R-040'), - }, - { - id: 'R-037', - appId: 'app-customer-support', - gateCommitId: '810fd671', - operator: 'alice', - createdAt: '2026-04-11 10:02', - description: 'historic', - yaml: sampleYaml('Customer Support Bot', 'R-037'), - }, - { - id: 'R-031', - appId: 'app-payments-workflow', - gateCommitId: '4ac82db1', - operator: 'alice', - createdAt: '2026-04-07 14:55', - description: 'initial deploy', - yaml: sampleYaml('Payments Workflow', 'R-031'), - }, -] - -export const mockInstances: Instance[] = [ - { - id: 'instance-cs', - appId: 'app-customer-support', - name: 'Customer Support', - description: 'Frontline CS assistant', - createdAt: '2026-02-10 12:23', - }, - { - id: 'instance-payments', - appId: 'app-payments-workflow', - name: 'Payments Orchestrator', - description: 'Payment intent processing', - createdAt: '2026-02-18 09:41', - }, - { - id: 'instance-marketing', - appId: 'app-marketing-copy', - name: 'Marketing Copy', - description: 'Ad copy generator', - createdAt: '2026-03-04 14:02', - }, - { - id: 'instance-onboarding-draft', - appId: 'app-onboarding-draft', - name: 'Onboarding Draft', - description: 'Draft assistant waiting for its first environment deployment', - createdAt: '2026-04-18 10:30', - }, -] - -export const mockDeployments: Deployment[] = [ - { - id: 'dep-cs-default', - instanceId: 'instance-cs', - environmentId: 'env-default', - activeReleaseId: 'R-042', - status: 'ready', - replicas: 1, - runtimeNote: 'Loaded in memory', - credentials: [ - { provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-prod' }, - { provider: 'Gmail', kind: 'plugin', credentialId: 'cred-gmail-key001' }, - ], - envVariables: [ - { key: 'dbkey', value: 'xxxxx', type: 'secret' }, - { key: 'keyno', value: '14', type: 'string' }, - ], - createdAt: '2026-02-10 12:25', - }, - { - id: 'dep-cs-prod', - instanceId: 'instance-cs', - environmentId: 'env-prod-isolated', - activeReleaseId: 'R-037', - status: 'ready', - replicas: 3, - runtimeNote: 'Loaded in memory', - credentials: [ - { provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-prod' }, - ], - envVariables: [], - createdAt: '2026-03-02 15:10', - }, - { - id: 'dep-payments-default', - instanceId: 'instance-payments', - environmentId: 'env-default', - activeReleaseId: 'R-031', - status: 'ready', - replicas: 1, - runtimeNote: 'Loaded in memory', - credentials: [ - { provider: 'Anthropic', kind: 'model', credentialId: 'cred-anthropic-prod' }, - ], - envVariables: [], - createdAt: '2026-04-07 15:00', - }, - { - id: 'dep-payments-prod', - instanceId: 'instance-payments', - environmentId: 'env-prod-isolated', - activeReleaseId: 'R-031', - targetReleaseId: 'R-043', - status: 'deploying', - replicas: 3, - runtimeNote: 'Replicas 3 / Runtime Shell retained', - credentials: [ - { provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-prod' }, - { provider: 'DeepSeek', kind: 'model', credentialId: 'cred-deepseek-prod' }, - { provider: 'Gmail', kind: 'plugin', credentialId: 'cred-gmail-key001' }, - { provider: 'Notion', kind: 'plugin', credentialId: 'cred-notion-key001' }, - ], - envVariables: [ - { key: 'kn', value: 'this-is-kn-value', type: 'string' }, - { key: 'dbkey', value: 'xxxxx', type: 'secret' }, - ], - createdAt: '2026-04-15 19:08', - }, - { - id: 'dep-marketing-qa', - instanceId: 'instance-marketing', - environmentId: 'env-qa-host', - activeReleaseId: 'R-040', - failedReleaseId: 'R-041', - status: 'deploy_failed', - errorMessage: 'Credential validate failed for openai-test', - runtimeNote: 'AppRunner Daemon Mode', - credentials: [ - { provider: 'OpenAI', kind: 'model', credentialId: 'cred-openai-test' }, - ], - envVariables: [], - createdAt: '2026-04-13 15:10', - }, -] - -export const mockApiKeys: ApiKey[] = [ - { - id: 'apikey-cs-default', - instanceId: 'instance-cs', - environmentId: 'env-default', - label: 'default-key-001', - value: 'app-cs-default-b1c72a8f9d', - createdAt: '2026-02-10 12:25', - }, - { - id: 'apikey-cs-prod', - instanceId: 'instance-cs', - environmentId: 'env-prod-isolated', - label: 'prod-key-001', - value: 'app-cs-prod-8a31f22d7c', - createdAt: '2026-03-02 15:11', - }, - { - id: 'apikey-payments-default', - instanceId: 'instance-payments', - environmentId: 'env-default', - label: 'default-key-001', - value: 'app-pay-default-4c91a7e03b', - createdAt: '2026-04-07 15:01', - }, - { - id: 'apikey-payments-prod', - instanceId: 'instance-payments', - environmentId: 'env-prod-isolated', - label: 'prod-key-001', - value: 'app-pay-prod-de1f5b8a62', - createdAt: '2026-04-15 19:10', - }, - { - id: 'apikey-marketing-qa', - instanceId: 'instance-marketing', - environmentId: 'env-qa-host', - label: 'qa-key-001', - value: 'app-mk-qa-91ab2c3de4', - createdAt: '2026-04-13 15:12', - }, -] - -export const mockMembers: Member[] = [ - { id: 'mem-ava', name: 'Ava Chen', email: 'ava.chen@dify.ai' }, - { id: 'mem-lucas', name: 'Lucas Martin', email: 'lucas.martin@dify.ai' }, - { id: 'mem-rin', name: 'Rin Tanaka', email: 'rin.tanaka@dify.ai' }, - { id: 'mem-owen', name: 'Owen Walker', email: 'owen.walker@dify.ai' }, - { id: 'mem-noa', name: 'Noa Baker', email: 'noa.baker@dify.ai' }, - { id: 'mem-harper', name: 'Harper Young', email: 'harper.young@dify.ai' }, - { id: 'mem-ellis', name: 'Ellis Park', email: 'ellis.park@dify.ai' }, - { id: 'mem-zane', name: 'Zane Okafor', email: 'zane.okafor@dify.ai' }, - { id: 'mem-iris', name: 'Iris Novak', email: 'iris.novak@dify.ai' }, - { id: 'mem-mia', name: 'Mia Delgado', email: 'mia.delgado@dify.ai' }, - { id: 'mem-kai', name: 'Kai Andersson', email: 'kai.andersson@dify.ai' }, - { id: 'mem-ren', name: 'Ren Fujimoto', email: 'ren.fujimoto@dify.ai' }, -] - -export const mockMemberGroups: MemberGroup[] = [ - { id: 'group-engineering', name: 'Engineering', memberCount: 85, description: 'Platform, backend and infra engineers' }, - { id: 'group-support', name: 'Customer Success', memberCount: 118, description: 'Tier 1 and Tier 2 customer support' }, - { id: 'group-design', name: 'Design', memberCount: 14, description: 'Product and brand designers' }, - { id: 'group-ops', name: 'Operations', memberCount: 9, description: 'Admins and workspace operators' }, -] - -export const mockAccess: InstanceAccess[] = [ - { - instanceId: 'instance-cs', - enabled: { api: true, runAccess: true }, - webappUrl: 'https://my.webapp.com/afc28cef', - mcpUrl: 'https://mcp.dify.internal/instance-cs', - envPermissions: [ - { environmentId: 'env-prod-isolated', kind: 'organization' }, - { - environmentId: 'env-default', - kind: 'specific', - memberIds: ['mem-ava', 'mem-lucas', 'mem-rin'], - groupIds: ['group-engineering', 'group-support'], - }, - { - environmentId: 'env-testing', - kind: 'specific', - memberIds: ['mem-owen'], - groupIds: [], - }, - ], - }, - { - instanceId: 'instance-payments', - enabled: { api: true, runAccess: false }, - webappUrl: 'https://my.webapp.com/payments', - mcpUrl: 'https://mcp.dify.internal/instance-payments', - envPermissions: [ - { - environmentId: 'env-prod-isolated', - kind: 'specific', - memberIds: ['mem-noa', 'mem-harper', 'mem-ellis'], - groupIds: ['group-ops'], - }, - { environmentId: 'env-default', kind: 'organization' }, - ], - }, - { - instanceId: 'instance-marketing', - enabled: { api: true, runAccess: true }, - webappUrl: 'https://my.webapp.com/marketing', - envPermissions: [ - { environmentId: 'env-default', kind: 'anyone' }, - ], - }, - { - instanceId: 'instance-onboarding-draft', - enabled: { api: false, runAccess: false }, - envPermissions: [], - }, -] diff --git a/web/app/components/deployments/rollback-modal.tsx b/web/app/components/deployments/rollback-modal.tsx index cf853f7173..5941967ed2 100644 --- a/web/app/components/deployments/rollback-modal.tsx +++ b/web/app/components/deployments/rollback-modal.tsx @@ -11,6 +11,14 @@ import { } from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { + activeRelease, + deployedRows, + environmentId, + environmentName, + releaseCommit, + releaseLabel, +} from './api-utils' import { useDeploymentsStore } from './store' import { useSourceApps } from './use-source-apps' @@ -26,25 +34,26 @@ const InfoRow: FC<{ label: string, value: string }> = ({ label, value }) => { const RollbackModal: FC = () => { const { t } = useTranslation('deployments') const modal = useDeploymentsStore(state => state.rollbackModal) - const deployments = useDeploymentsStore(state => state.deployments) - const instances = useDeploymentsStore(state => state.instances) - const releases = useDeploymentsStore(state => state.releases) - const environments = useDeploymentsStore(state => state.environments) + const appData = useDeploymentsStore(state => modal.appId ? state.appData[modal.appId] : undefined) const closeRollbackModal = useDeploymentsStore(state => state.closeRollbackModal) const rollbackDeployment = useDeploymentsStore(state => state.rollbackDeployment) const { appMap } = useSourceApps() - const deployment = deployments.find(d => d.id === modal.deploymentId) - const targetRelease = releases.find(r => r.id === modal.targetReleaseId) - const currentRelease = releases.find(r => r.id === deployment?.activeReleaseId) - const environment = environments.find(env => env.id === deployment?.environmentId) - const instance = instances.find(i => i.id === deployment?.instanceId) - const app = instance ? appMap.get(instance.appId) : undefined + const currentRow = deployedRows(appData?.environmentDeployments.environmentDeployments) + .find(row => environmentId(row.environment) === modal.environmentId) + const targetRelease = [ + ...(appData?.candidates.releases ?? []), + ...(appData?.releaseHistory.data?.map(row => row.release).filter(release => !!release) ?? []), + ].find(release => release?.id === modal.targetReleaseId) + const currentRelease = activeRelease(currentRow) + const environment = currentRow?.environment + ?? appData?.candidates.environmentOptions?.find(env => env.id === modal.environmentId) + const app = modal.appId ? appMap.get(modal.appId) : undefined const confirm = () => { - if (!modal.deploymentId || !modal.targetReleaseId) + if (!modal.appId || !modal.environmentId || !modal.targetReleaseId) return - rollbackDeployment(modal.deploymentId, modal.targetReleaseId) + rollbackDeployment(modal.appId, modal.environmentId, modal.targetReleaseId) } return ( @@ -55,23 +64,23 @@ const RollbackModal: FC = () => {
- {t('rollback.title', { release: targetRelease?.id ?? '-' })} + {t('rollback.title', { release: releaseLabel(targetRelease) })} {t('rollback.description')}
- + - +
diff --git a/web/app/components/deployments/store.ts b/web/app/components/deployments/store.ts index 25f3b3a092..3e1098042f 100644 --- a/web/app/components/deployments/store.ts +++ b/web/app/components/deployments/store.ts @@ -1,56 +1,43 @@ -import type { AccessMethod, AccessPermissionKind, ApiKey, AppInfo, CredentialBinding, Deployment, Environment, EnvVariable, Instance, InstanceAccess, Release } from './types' +import type { AppInfo } from './types' +import type { APIToken, BindingsProto } from '@/contract/console/deployments' +import type { DeploymentAppData } from '@/service/deployments' import { create } from 'zustand' -import { MOCK_APP_ID_SLOTS, mockAccess, mockApiKeys, mockDeployments, mockEnvironments, mockInstances, mockReleases } from './mock-data' - -const DEPLOY_MOCK_DURATION_MS = 2000 -let releaseCounter = 44 -let apiKeyCounter = 100 -let instanceCounter = 100 - -function generateReleaseId() { - const id = `R-0${releaseCounter}` - releaseCounter += 1 - return id -} - -function generateApiKeyId() { - const id = `apikey-${apiKeyCounter}` - apiKeyCounter += 1 - return id -} - -function generateInstanceId() { - const id = `instance-new-${instanceCounter}` - instanceCounter += 1 - return id -} - -function randomGateCommitId() { - return Math.random().toString(16).slice(2, 10) -} - -function nowStamp() { - return new Date().toISOString().replace('T', ' ').slice(0, 16) -} +import { + cancelDeployment, + createApiKey, + createDeployment, + deleteApiKey, + fetchDeploymentAppData, + patchAccessChannel, + rollbackEnvironment, + undeployEnvironment, + updateEnvironmentAccessPolicy, +} from '@/service/deployments' export type StartDeployParams = { - instanceId: string + appId: string environmentId: string releaseId?: string releaseNote?: string - credentials: CredentialBinding[] - envVariables: EnvVariable[] + bindings?: BindingsProto } type OpenDeployDrawerParams = { - instanceId: string + appId: string environmentId?: string releaseId?: string } type OpenRollbackParams = { - deploymentId: string + appId: string + environmentId: string targetReleaseId: string + deploymentId?: string +} + +type CreatedApiToken = Pick & { + appId: string + token: string } export type CreateInstanceParams = { @@ -60,22 +47,20 @@ export type CreateInstanceParams = { } type DeploymentsState = { - environments: Environment[] - instances: Instance[] - deployments: Deployment[] - releases: Release[] - apiKeys: ApiKey[] - access: InstanceAccess[] - seededAppIds: string[] | null + sourceApps: AppInfo[] + appData: Record + createdApiToken?: CreatedApiToken deployDrawer: { open: boolean - instanceId?: string + appId?: string environmentId?: string releaseId?: string } rollbackModal: { open: boolean + appId?: string + environmentId?: string deploymentId?: string targetReleaseId?: string } @@ -91,40 +76,37 @@ type DeploymentsState = { closeCreateInstanceModal: () => void seedInstancesFromApps: (apps: AppInfo[]) => void + applyAppData: (data: DeploymentAppData) => void + refreshAppData: (appId: string) => Promise createInstance: (params: CreateInstanceParams) => string - updateInstance: (instanceId: string, patch: Partial>) => void - switchSourceApp: (instanceId: string, appId: string) => void - deleteInstance: (instanceId: string) => void + updateInstance: (appId: string, patch: Partial>) => void + switchSourceApp: (appId: string, nextAppId: string) => void + deleteInstance: (appId: string) => void - startDeploy: (params: StartDeployParams) => void - retryDeploy: (deploymentId: string) => void - rollbackDeployment: (deploymentId: string, targetReleaseId: string) => void - undeployDeployment: (deploymentId: string) => void + startDeploy: (params: StartDeployParams) => Promise + retryDeploy: (appId: string, environmentId: string, targetReleaseId: string) => Promise + rollbackDeployment: (appId: string, environmentId: string, targetReleaseId: string) => Promise + undeployDeployment: (appId: string, environmentId: string, deploymentId?: string, isDeploying?: boolean) => Promise - generateApiKey: (instanceId: string, environmentId: string) => void - revokeApiKey: (apiKeyId: string) => void - toggleAccessMethod: (instanceId: string, method: AccessMethod, enabled: boolean) => void - setEnvAccessPermission: (instanceId: string, environmentId: string, kind: AccessPermissionKind) => void - setEnvAccessMembers: ( - instanceId: string, + generateApiKey: (appId: string, environmentId: string) => Promise + revokeApiKey: (appId: string, environmentId: string, apiKeyId: string) => Promise + clearCreatedApiToken: () => void + toggleAccessChannel: (appId: string, channel: string, enabled: boolean, expectedVersion: number) => Promise + setEnvironmentAccessPolicy: ( + appId: string, environmentId: string, - members: { memberIds: string[], groupIds: string[] }, - ) => void -} - -function updateDeployment(deployments: Deployment[], deploymentId: string, patch: Partial): Deployment[] { - return deployments.map(item => item.id === deploymentId ? { ...item, ...patch } : item) + channel: string, + enabled: boolean, + accessMode: string, + expectedVersion: number, + ) => Promise } export const useDeploymentsStore = create((set, get) => ({ - environments: mockEnvironments, - instances: mockInstances, - deployments: mockDeployments, - releases: mockReleases, - apiKeys: mockApiKeys, - access: mockAccess, - seededAppIds: null, + sourceApps: [], + appData: {}, + createdApiToken: undefined, deployDrawer: { open: false }, rollbackModal: { open: false }, @@ -133,299 +115,128 @@ export const useDeploymentsStore = create((set, get) => ({ openDeployDrawer: params => set({ deployDrawer: { open: true, - instanceId: params.instanceId, + appId: params.appId, environmentId: params.environmentId, releaseId: params.releaseId, }, }), closeDeployDrawer: () => set({ deployDrawer: { open: false } }), - openRollbackModal: ({ deploymentId, targetReleaseId }) => set({ - rollbackModal: { open: true, deploymentId, targetReleaseId }, + openRollbackModal: ({ appId, environmentId, deploymentId, targetReleaseId }) => set({ + rollbackModal: { open: true, appId, environmentId, deploymentId, targetReleaseId }, }), closeRollbackModal: () => set({ rollbackModal: { open: false } }), openCreateInstanceModal: () => set({ createInstanceModal: { open: true } }), closeCreateInstanceModal: () => set({ createInstanceModal: { open: false } }), - seedInstancesFromApps: (apps) => { - if (apps.length === 0) - return - const realIds = apps.map(a => a.id) - const previous = get().seededAppIds - const unchanged - = previous !== null - && previous.length === realIds.length - && previous.every((id, i) => id === realIds[i]) - if (unchanged) - return + seedInstancesFromApps: apps => set(state => ({ + sourceApps: apps, + appData: Object.fromEntries( + Object.entries(state.appData).filter(([appId]) => apps.some(app => app.id === appId)), + ), + })), - const slotMap: Record = {} - MOCK_APP_ID_SLOTS.forEach((mockId, idx) => { - const real = apps[idx % apps.length]! - slotMap[mockId] = real.id - }) + applyAppData: data => set(state => ({ + appData: { + ...state.appData, + [data.appId]: data, + }, + })), + refreshAppData: async (appId) => { + const data = await fetchDeploymentAppData(appId) + get().applyAppData(data) + }, + + createInstance: ({ appId }) => { + set({ createInstanceModal: { open: false } }) + return appId + }, + + updateInstance: (appId, patch) => { set(state => ({ - instances: state.instances.map((i) => { - const bindingProfileId = i.bindingProfileId ?? i.appId - return { - ...i, - appId: slotMap[bindingProfileId] ?? i.appId, - bindingProfileId, - } - }), - releases: state.releases.map(r => ({ - ...r, - appId: slotMap[r.appId] ?? r.appId, - })), - seededAppIds: realIds, + sourceApps: state.sourceApps.map(app => app.id === appId ? { ...app, ...patch } : app), })) }, - createInstance: ({ appId, name, description }) => { - const id = generateInstanceId() - const instance: Instance = { - id, - appId, - name, - description, - createdAt: nowStamp(), - } + switchSourceApp: () => undefined, + + deleteInstance: (appId) => { set(state => ({ - instances: [...state.instances, instance], - access: [ - ...state.access, - { - instanceId: id, - enabled: { api: true, runAccess: true }, - envPermissions: [], + 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), + ), + })) + }, + + startDeploy: async ({ appId, environmentId, releaseId, releaseNote, bindings }) => { + set({ deployDrawer: { open: false } }) + await createDeployment({ appId, environmentId, releaseId, releaseNote, bindings }) + await get().refreshAppData(appId) + }, + + retryDeploy: async (appId, environmentId, targetReleaseId) => { + await rollbackEnvironment(appId, environmentId, targetReleaseId) + await get().refreshAppData(appId) + }, + + rollbackDeployment: async (appId, environmentId, targetReleaseId) => { + set({ rollbackModal: { open: false } }) + await rollbackEnvironment(appId, environmentId, targetReleaseId) + await get().refreshAppData(appId) + }, + + undeployDeployment: async (appId, environmentId, deploymentId, isDeploying) => { + if (isDeploying && deploymentId) + await cancelDeployment(appId, environmentId, deploymentId) + else + await undeployEnvironment(appId, environmentId) + await get().refreshAppData(appId) + }, + + generateApiKey: async (appId, environmentId) => { + const appData = get().appData[appId] + const existingCount = appData?.accessConfig.developerApi?.apiKeys?.filter(key => key.environmentId === environmentId).length ?? 0 + const environmentName = appData + ?.environmentDeployments + .environmentDeployments + ?.find(row => row.environment?.id === environmentId) + ?.environment + ?.name ?? 'env' + const label = `${environmentName}-key-${String(existingCount + 1).padStart(3, '0')}` + const response = await createApiKey(appId, environmentId, label) + await get().refreshAppData(appId) + if (response.apiToken?.token) { + set({ + createdApiToken: { + id: response.apiToken.id, + appId, + environmentId, + maskedPrefix: response.apiToken.maskedPrefix, + name: response.apiToken.name || label, + token: response.apiToken.token, }, - ], - createInstanceModal: { open: false }, - })) - return id - }, - - updateInstance: (instanceId, patch) => { - set(state => ({ - instances: state.instances.map(item => item.id === instanceId ? { ...item, ...patch } : item), - })) - }, - - switchSourceApp: (instanceId, appId) => { - set(state => ({ - instances: state.instances.map(item => item.id === instanceId ? { ...item, appId, bindingProfileId: appId } : item), - })) - }, - - deleteInstance: (instanceId) => { - set(state => ({ - instances: state.instances.filter(item => item.id !== instanceId), - deployments: state.deployments.filter(d => d.instanceId !== instanceId), - apiKeys: state.apiKeys.filter(k => k.instanceId !== instanceId), - access: state.access.filter(a => a.instanceId !== instanceId), - })) - }, - - startDeploy: ({ instanceId, environmentId, releaseId, releaseNote, credentials, envVariables }) => { - const instance = get().instances.find(i => i.id === instanceId) - if (!instance) - return - - let targetReleaseId = releaseId - let newRelease: Release | undefined - if (!targetReleaseId) { - const newReleaseId = generateReleaseId() - const trimmedNote = releaseNote?.trim() - newRelease = { - id: newReleaseId, - appId: instance.appId, - gateCommitId: randomGateCommitId(), - operator: 'you', - createdAt: nowStamp(), - description: trimmedNote || 'draft deploy', - yaml: `# Release: ${newReleaseId}\napp:\n name: ${instance.appId}\n mode: advanced-chat\n`, - } - targetReleaseId = newReleaseId - } - - const existing = get().deployments.find(d => d.instanceId === instanceId && d.environmentId === environmentId) - let nextDeployments: Deployment[] - let targetDeploymentId: string - if (existing) { - targetDeploymentId = existing.id - nextDeployments = updateDeployment(get().deployments, existing.id, { - status: 'deploying', - targetReleaseId, - failedReleaseId: undefined, - credentials, - envVariables, - errorMessage: undefined, }) } - else { - targetDeploymentId = `dep-${instanceId}-${environmentId}-${Date.now()}` - const newDeployment: Deployment = { - id: targetDeploymentId, - instanceId, - environmentId, - activeReleaseId: targetReleaseId, - targetReleaseId, - status: 'deploying', - runtimeNote: 'Loading...', - credentials, - envVariables, - createdAt: nowStamp(), - } - nextDeployments = [...get().deployments, newDeployment] - } - - set(state => ({ - deployments: nextDeployments, - releases: newRelease ? [newRelease, ...state.releases] : state.releases, - deployDrawer: { open: false }, - })) - - setTimeout(() => { - set(state => ({ - deployments: updateDeployment(state.deployments, targetDeploymentId, { - activeReleaseId: targetReleaseId, - targetReleaseId: undefined, - failedReleaseId: undefined, - status: 'ready', - runtimeNote: 'Loaded in memory', - }), - })) - }, DEPLOY_MOCK_DURATION_MS) }, - retryDeploy: (deploymentId) => { - const deployment = get().deployments.find(d => d.id === deploymentId) - if (!deployment) - return - const targetReleaseId = deployment.failedReleaseId ?? deployment.targetReleaseId ?? deployment.activeReleaseId - set(state => ({ - deployments: updateDeployment(state.deployments, deploymentId, { - status: 'deploying', - targetReleaseId, - failedReleaseId: undefined, - errorMessage: undefined, - }), - })) - setTimeout(() => { - set(state => ({ - deployments: updateDeployment(state.deployments, deploymentId, { - activeReleaseId: targetReleaseId, - targetReleaseId: undefined, - status: 'ready', - runtimeNote: 'Loaded in memory', - }), - })) - }, DEPLOY_MOCK_DURATION_MS) + revokeApiKey: async (appId, environmentId, apiKeyId) => { + await deleteApiKey(appId, environmentId, apiKeyId) + await get().refreshAppData(appId) }, - rollbackDeployment: (deploymentId, targetReleaseId) => { - set(state => ({ - deployments: updateDeployment(state.deployments, deploymentId, { - status: 'deploying', - targetReleaseId, - failedReleaseId: undefined, - errorMessage: undefined, - }), - rollbackModal: { open: false }, - })) - setTimeout(() => { - set(state => ({ - deployments: updateDeployment(state.deployments, deploymentId, { - activeReleaseId: targetReleaseId, - targetReleaseId: undefined, - status: 'ready', - runtimeNote: 'Loaded in memory', - }), - })) - }, DEPLOY_MOCK_DURATION_MS) + clearCreatedApiToken: () => set({ createdApiToken: undefined }), + + toggleAccessChannel: async (appId, channel, enabled, expectedVersion) => { + await patchAccessChannel(appId, channel, enabled, expectedVersion) + await get().refreshAppData(appId) }, - undeployDeployment: (deploymentId) => { - set(state => ({ - deployments: state.deployments.filter(d => d.id !== deploymentId), - })) - }, - - generateApiKey: (instanceId, environmentId) => { - const existingCount = get().apiKeys.filter(k => k.instanceId === instanceId && k.environmentId === environmentId).length - const env = get().environments.find(e => e.id === environmentId) - const labelPrefix = env?.name ?? 'env' - const label = `${labelPrefix}-key-${String(existingCount + 1).padStart(3, '0')}` - const suffix = Math.random().toString(16).slice(2, 12) - const newKey: ApiKey = { - id: generateApiKeyId(), - instanceId, - environmentId, - label, - value: `app-${instanceId.slice(-4)}-${suffix}`, - createdAt: nowStamp(), - } - set(state => ({ apiKeys: [newKey, ...state.apiKeys] })) - }, - - revokeApiKey: (apiKeyId) => { - set(state => ({ - apiKeys: state.apiKeys.filter(k => k.id !== apiKeyId), - })) - }, - - toggleAccessMethod: (instanceId, method, enabled) => { - set(state => ({ - access: state.access.map((a) => { - if (a.instanceId !== instanceId) - return a - return { ...a, enabled: { ...a.enabled, [method]: enabled } } - }), - })) - }, - - setEnvAccessPermission: (instanceId, environmentId, kind) => { - set(state => ({ - access: state.access.map((a) => { - if (a.instanceId !== instanceId) - return a - const existingIdx = a.envPermissions.findIndex(p => p.environmentId === environmentId) - const existing = existingIdx >= 0 ? a.envPermissions[existingIdx] : undefined - const nextEntry = kind === 'specific' - ? { - environmentId, - kind, - memberIds: existing?.memberIds ?? [], - groupIds: existing?.groupIds ?? [], - } - : { environmentId, kind } - const envPermissions = existingIdx >= 0 - ? a.envPermissions.map((p, i) => (i === existingIdx ? nextEntry : p)) - : [...a.envPermissions, nextEntry] - return { ...a, envPermissions } - }), - })) - }, - - setEnvAccessMembers: (instanceId, environmentId, { memberIds, groupIds }) => { - set(state => ({ - access: state.access.map((a) => { - if (a.instanceId !== instanceId) - return a - const existingIdx = a.envPermissions.findIndex(p => p.environmentId === environmentId) - const nextEntry = { - environmentId, - kind: 'specific' as AccessPermissionKind, - memberIds, - groupIds, - } - const envPermissions = existingIdx >= 0 - ? a.envPermissions.map((p, i) => (i === existingIdx ? nextEntry : p)) - : [...a.envPermissions, nextEntry] - return { ...a, envPermissions } - }), - })) + setEnvironmentAccessPolicy: async (appId, environmentId, channel, enabled, accessMode, expectedVersion) => { + await updateEnvironmentAccessPolicy(appId, environmentId, channel, enabled, accessMode, [], expectedVersion) + await get().refreshAppData(appId) }, })) diff --git a/web/app/components/deployments/types.ts b/web/app/components/deployments/types.ts index 30e6ce3ba5..da3d7730ba 100644 --- a/web/app/components/deployments/types.ts +++ b/web/app/components/deployments/types.ts @@ -1,54 +1,11 @@ export type EnvironmentMode = 'shared' | 'isolated' -export type EnvironmentBackend = 'k8s' | 'host' export type EnvironmentHealth = 'ready' | 'degraded' export type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' export type AppMode = 'chat' | 'agent-chat' | 'workflow' | 'completion' | 'advanced-chat' -export type AccessMethod = 'api' | 'runAccess' - -export type AccessPermissionKind = 'organization' | 'specific' | 'external' | 'anyone' - -export type EnvAccessPermission = { - environmentId: string - kind: AccessPermissionKind - memberIds?: string[] - groupIds?: string[] -} - -export type Member = { - id: string - name: string - email: string -} - -export type MemberGroup = { - id: string - name: string - memberCount: number - description?: string -} - -export type Environment = { - id: string - name: string - namespace: string - description?: string - mode: EnvironmentMode - backend: EnvironmentBackend - health: EnvironmentHealth - createdAt: string -} - -export type Credential = { - id: string - name: string - provider: string - kind: 'model' | 'plugin' - scope: string - validated: boolean -} +export type AccessPermissionKind = 'organization' | 'specific' | 'anyone' export type AppInfo = { id: string @@ -60,67 +17,3 @@ export type AppInfo = { iconUrl?: string | null description?: string } - -export type Release = { - id: string - appId: string - gateCommitId: string - operator: string - createdAt: string - description?: string - yaml: string -} - -export type CredentialBinding = { - provider: string - kind: 'model' | 'plugin' - credentialId?: string -} - -export type EnvVariable = { - key: string - value: string - type: 'string' | 'secret' -} - -export type Deployment = { - id: string - instanceId: string - environmentId: string - activeReleaseId: string - targetReleaseId?: string - failedReleaseId?: string - status: DeployStatus - replicas?: number - errorMessage?: string - runtimeNote?: string - credentials: CredentialBinding[] - envVariables: EnvVariable[] - createdAt: string -} - -export type Instance = { - id: string - appId: string - bindingProfileId?: string | undefined - name: string - description?: string - createdAt: string -} - -export type ApiKey = { - id: string - instanceId: string - environmentId: string - label: string - value: string - createdAt: string -} - -export type InstanceAccess = { - instanceId: string - enabled: Record - webappUrl?: string - mcpUrl?: string - envPermissions: EnvAccessPermission[] -} diff --git a/web/app/components/deployments/use-deployment-data.ts b/web/app/components/deployments/use-deployment-data.ts new file mode 100644 index 0000000000..45c79ecf90 --- /dev/null +++ b/web/app/components/deployments/use-deployment-data.ts @@ -0,0 +1,42 @@ +'use client' + +import type { AppInfo } from './types' +import { useQueries } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { fetchDeploymentAppData } from '@/service/deployments' +import { useDeploymentsStore } from './store' + +type UseDeploymentDataOptions = { + enabled?: boolean +} + +export function useDeploymentData(apps: AppInfo[], options: UseDeploymentDataOptions = {}) { + const { enabled = true } = options + const applyAppData = useDeploymentsStore(state => state.applyAppData) + + const queries = useQueries({ + queries: apps.map(app => ({ + queryKey: ['deployments', 'app-data', app.id], + queryFn: () => fetchDeploymentAppData(app.id), + enabled: enabled && Boolean(app.id), + staleTime: 30 * 1000, + })), + }) + + const queriesRef = useRef(queries) + queriesRef.current = queries + const dataUpdatedAt = queries.map(query => query.dataUpdatedAt).join('|') + + useEffect(() => { + queriesRef.current.forEach((query) => { + if (query.data) + applyAppData(query.data) + }) + }, [applyAppData, dataUpdatedAt]) + + return { + isLoading: queries.some(query => query.isLoading), + isFetching: queries.some(query => query.isFetching), + isError: queries.some(query => query.isError), + } +} diff --git a/web/app/components/deployments/use-source-apps.ts b/web/app/components/deployments/use-source-apps.ts index bdf63b4ec1..ae2a9d717d 100644 --- a/web/app/components/deployments/use-source-apps.ts +++ b/web/app/components/deployments/use-source-apps.ts @@ -4,6 +4,7 @@ import type { App } from '@/types/app' import { useEffect, useMemo } from 'react' import { useAppList } from '@/service/use-apps' import { useDeploymentsStore } from './store' +import { useDeploymentData } from './use-deployment-data' const MAX_SOURCE_APPS = 100 @@ -46,12 +47,14 @@ export function useSourceApps(options: UseSourceAppsOptions = {}) { seedInstancesFromApps(apps) }, [apps, seedInstancesFromApps]) + const deploymentData = useDeploymentData(apps, { enabled: enabled && apps.length > 0 }) + return { apps, appMap, - isLoading, - isFetching, - isError, + isLoading: isLoading || deploymentData.isLoading, + isFetching: isFetching || deploymentData.isFetching, + isError: isError || deploymentData.isError, isEmpty: !isLoading && apps.length === 0, } } diff --git a/web/app/components/header/deployments-nav/index.tsx b/web/app/components/header/deployments-nav/index.tsx index b2dc6506a3..7137fefb48 100644 --- a/web/app/components/header/deployments-nav/index.tsx +++ b/web/app/components/header/deployments-nav/index.tsx @@ -17,28 +17,31 @@ const DeploymentsNav = () => { const params = useParams<{ instanceId?: string }>() const instanceId = params?.instanceId - const instances = useDeploymentsStore(state => state.instances) + const sourceApps = useDeploymentsStore(state => state.sourceApps) const openCreateInstanceModal = useDeploymentsStore(state => state.openCreateInstanceModal) const { appMap } = useSourceApps({ enabled: isActive }) + const apps = useMemo( + () => sourceApps.length > 0 ? sourceApps : [...appMap.values()], + [appMap, sourceApps], + ) const navigationItems = useMemo(() => { if (!isActive) return [] - return instances.map((instance) => { - const app = appMap.get(instance.appId) + return apps.map((app) => { return { - id: instance.id, - name: instance.name, - link: `/deployments/${instance.id}/overview`, - icon_type: (app?.iconType ?? null) as AppIconType | null, - icon: app?.icon ?? '', - icon_background: app?.iconBackground ?? null, - icon_url: app?.iconUrl ?? null, - mode: app?.mode as unknown as AppModeEnum | undefined, + id: app.id, + name: app.name, + link: `/deployments/${app.id}/overview`, + icon_type: (app.iconType ?? null) as AppIconType | null, + icon: app.icon ?? '', + icon_background: app.iconBackground ?? null, + icon_url: app.iconUrl ?? null, + mode: app.mode as unknown as AppModeEnum | undefined, } }) - }, [instances, appMap, isActive]) + }, [apps, isActive]) const curNav = useMemo(() => { if (!instanceId) diff --git a/web/contract/console/deployments.ts b/web/contract/console/deployments.ts new file mode 100644 index 0000000000..8ff8593e70 --- /dev/null +++ b/web/contract/console/deployments.ts @@ -0,0 +1,703 @@ +import { type } from '@orpc/contract' +import { base } from '../base' + +type Timestamp = string + +export type ConsoleAppSummary = { + id?: string + name?: string + description?: string + icon?: string + mode?: string + status?: string + createdAt?: Timestamp +} + +export type ConsoleEnvironmentSummary = { + id?: string + name?: string + description?: string + runtime?: string + type?: string + status?: string + tags?: string[] +} + +export type ConsoleReleaseSummary = { + id?: string + displayId?: string + status?: string + description?: string + commitId?: string + createdAt?: Timestamp + name?: string +} + +export type LastErrorProto = { + phase?: string + code?: string + message?: string + releaseId?: string +} + +export type ConsoleInstanceSummary = { + id?: string + replicas?: number + status?: string + desiredReleaseId?: string + desiredReleaseDisplayId?: string + observedReleaseId?: string + observedReleaseDisplayId?: string + currentDeploymentId?: string + lastDeployedAt?: Timestamp + lastReadyAt?: Timestamp + lastError?: LastErrorProto +} + +export type ConsoleActions = { + canDeploy?: boolean + canDeployAnotherRelease?: boolean + canCancel?: boolean + canUndeploy?: boolean + canRollback?: boolean + canViewProgress?: boolean + canViewLogs?: boolean + disabledReason?: string +} + +export type ConsoleWarning = { + code?: string + message?: string +} + +export type DeploymentSummaryRow = { + environmentId?: string + environmentName?: string + releaseId?: string + releaseDisplayId?: string + status?: string +} + +export type ChannelSummary = { + enabled?: boolean +} + +export type AccessSummary = { + webapp?: ChannelSummary + cli?: ChannelSummary + api?: ChannelSummary + mcp?: ChannelSummary +} + +export type GetDeploymentOverviewReply = { + app?: ConsoleAppSummary + deployments?: DeploymentSummaryRow[] + access?: AccessSummary + warnings?: ConsoleWarning[] +} + +export type RuntimeBindingDisplay = { + slot?: string + displayName?: string + maskedValue?: string +} + +export type RuntimeBindings = { + credentials?: RuntimeBindingDisplay[] + envVars?: RuntimeBindingDisplay[] +} + +export type RuntimeEndpoints = { + run?: string + health?: string +} + +export type ObservedRuntime = { + release?: ConsoleReleaseSummary + bindings?: RuntimeBindings + endpoints?: RuntimeEndpoints +} + +export type PendingDeployment = { + deploymentId?: string + release?: ConsoleReleaseSummary + bindings?: RuntimeBindings +} + +export type EnvironmentDeploymentRow = { + environment?: ConsoleEnvironmentSummary + instance?: ConsoleInstanceSummary + observedRuntime?: ObservedRuntime + pendingDeployment?: PendingDeployment + actions?: ConsoleActions +} + +export type Pagination = { + totalCount?: number + perPage?: number + currentPage?: number + totalPages?: number +} + +export type ListEnvironmentDeploymentsReply = { + environmentDeployments?: EnvironmentDeploymentRow[] + pagination?: Pagination +} + +export type EnvironmentOption = { + id?: string + name?: string + type?: string + status?: string + description?: string + tags?: string[] + disabled?: boolean + disabledReason?: string +} + +export type ListDeploymentCandidatesReply = { + defaultReleaseId?: string + releases?: ConsoleReleaseSummary[] + environmentOptions?: EnvironmentOption[] +} + +export type CurrentInstanceState = { + instanceId?: string + status?: string + observedReleaseDisplayId?: string +} + +export type ConsoleCredentialOption = { + id?: string + displayName?: string + pluginId?: string + provider?: string +} + +export type ConsoleEnvVarOption = { + id?: string + name?: string + maskedValue?: string + valueType?: string + version?: number +} + +export type DeploymentSlot = { + kind?: string + slot?: string + label?: string + required?: boolean + selectedCredentialId?: string + selectedEnvVarId?: string + credentialOptions?: ConsoleCredentialOption[] + envVarOptions?: ConsoleEnvVarOption[] + missing?: boolean + missingReason?: string +} + +export type DeploymentBlocker = { + code?: string + message?: string +} + +export type GetDeploymentPlanReply = { + release?: ConsoleReleaseSummary + environment?: ConsoleEnvironmentSummary + currentInstance?: CurrentInstanceState + slots?: DeploymentSlot[] + canDeploy?: boolean + blockers?: DeploymentBlocker[] +} + +export type UserDisplay = { + id?: string + displayName?: string +} + +export type DeployedToSummary = { + environmentId?: string + environmentName?: string + instanceStatus?: string +} + +export type ReleaseHistoryActions = { + canDeploy?: boolean + canViewDetail?: boolean + canDelete?: boolean +} + +export type ReleaseHistoryRow = { + release?: ConsoleReleaseSummary + createdBy?: UserDisplay + deployedTo?: DeployedToSummary[] + actions?: ReleaseHistoryActions +} + +export type ListReleaseHistoryReply = { + data?: ReleaseHistoryRow[] + pagination?: Pagination +} + +export type EffectivePolicySummary = { + channel?: string + enabled?: boolean + accessMode?: string + label?: string + subjectCount?: number + version?: number +} + +export type EnvironmentPolicySummary = { + environment?: ConsoleEnvironmentSummary + effectivePolicy?: EffectivePolicySummary +} + +export type UserAccessSummary = { + sharedChannels?: string[] + environmentPolicies?: EnvironmentPolicySummary[] +} + +export type WebAppAccessRow = { + environment?: ConsoleEnvironmentSummary + url?: string + publicCode?: string + canCopy?: boolean + canShowQrCode?: boolean + canRegenerate?: boolean + createNeeded?: boolean +} + +export type WebAppAccessSummary = { + supported?: boolean + enabled?: boolean + rows?: WebAppAccessRow[] +} + +export type UnsupportedChannelSummary = { + supported?: boolean + statusLabel?: string +} + +export type CliAccessSummary = { + supported?: boolean + enabled?: boolean + statusLabel?: string + url?: string +} + +export type DeveloperAPIKeySummary = { + id?: string + environmentId?: string + environmentName?: string + name?: string + maskedPrefix?: string + createdAt?: Timestamp +} + +export type DeveloperAPISummary = { + enabled?: boolean + apiKeys?: DeveloperAPIKeySummary[] +} + +export type GetAccessConfigReply = { + userAccess?: UserAccessSummary + webapp?: WebAppAccessSummary + mcp?: UnsupportedChannelSummary + cli?: CliAccessSummary + developerApi?: DeveloperAPISummary +} + +export type AccessSubjectDisplay = { + id?: string + subjectType?: string + name?: string + avatarUrl?: string + memberCount?: number +} + +export type AccessPolicyOption = { + mode?: string + label?: string + selected?: boolean + disabled?: boolean + groups?: AccessSubjectDisplay[] + members?: AccessSubjectDisplay[] +} + +export type AccessPolicyDetail = { + id?: string + channel?: string + enabled?: boolean + accessMode?: string + version?: number + options?: AccessPolicyOption[] +} + +export type GetEnvironmentAccessPolicyReply = { + policy?: AccessPolicyDetail +} + +export type AccessSubject = { + subjectId?: string + subjectType?: string +} + +export type AccessPolicy = { + id?: string + appId?: string + environmentId?: string + scopeType?: string + channel?: string + enabled?: boolean + accessMode?: string + subjects?: AccessSubject[] + version?: number + subjectCount?: number +} + +export type UpdateEnvironmentAccessPolicyReply = { + policy?: AccessPolicy +} + +export type SearchAccessSubjectsReply = { + data?: AccessSubjectDisplay[] +} + +export type PatchAccessChannelReply = { + policy?: EffectivePolicySummary +} + +export type Release = { + id?: string + appId?: string + seq?: number + displayId?: string + status?: string + gateCommitId?: string + dslVersion?: string + description?: string + requiredPluginIds?: string[] + requiredModelSlots?: string[] + requiredEnvVarNames?: string[] + createdAt?: Timestamp + readyAt?: Timestamp + name?: string +} + +export type CreateReleaseReply = { + release?: Release +} + +export type CredentialBindingProto = { + slot?: string + credentialId?: string +} + +export type EnvVarBindingProto = { + slot?: string + envVarId?: string +} + +export type BindingsProto = { + models?: CredentialBindingProto[] + plugins?: CredentialBindingProto[] + envVars?: EnvVarBindingProto[] +} + +export type CreateDeploymentReply = { + instanceId?: string + deploymentId?: string + status?: string +} + +export type CancelDeploymentReply = { + status?: string +} + +export type UndeployEnvironmentReply = { + deploymentId?: string +} + +export type RollbackEnvironmentReply = { + deploymentId?: string +} + +export type APIToken = { + id?: string + appId?: string + environmentId?: string + name?: string + token?: string + maskedPrefix?: string + createdAt?: Timestamp + lastUsedAt?: Timestamp +} + +export type ListEnvironmentAPITokensReply = { + data?: APIToken[] +} + +export type CreateEnvironmentAPITokenReply = { + apiToken?: APIToken +} + +export type DeleteEnvironmentAPITokenReply = Record + +export const deploymentOverviewContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/overview', + method: 'GET', + }) + .input(type<{ params: { appId: string } }>()) + .output(type()) + +export const environmentDeploymentsContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/environment-deployments', + method: 'GET', + }) + .input(type<{ + params: { appId: string } + query?: { + pageNumber?: number + resultsPerPage?: number + } + }>()) + .output(type()) + +export const deploymentCandidatesContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/deployment-candidates', + method: 'GET', + }) + .input(type<{ params: { appId: string } }>()) + .output(type()) + +export const deploymentPlanContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/releases/{releaseId}/deployment-plan', + method: 'GET', + }) + .input(type<{ + params: { + appId: string + environmentId: string + releaseId: string + } + }>()) + .output(type()) + +export const releaseHistoryContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/release-history', + method: 'GET', + }) + .input(type<{ + params: { appId: string } + query?: { + pageNumber?: number + resultsPerPage?: number + } + }>()) + .output(type()) + +export const accessConfigContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/access-config', + method: 'GET', + }) + .input(type<{ params: { appId: string } }>()) + .output(type()) + +export const environmentAccessPolicyContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/deploy/access-policies/{channel}', + method: 'GET', + }) + .input(type<{ + params: { + appId: string + environmentId: string + channel: string + } + }>()) + .output(type()) + +export const updateEnvironmentAccessPolicyContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/deploy/access-policies/{channel}', + method: 'PUT', + }) + .input(type<{ + params: { + appId: string + environmentId: string + channel: string + } + body: { + channel: string + enabled: boolean + accessMode: string + subjects: AccessSubject[] + expectedVersion: number + } + }>()) + .output(type()) + +export const searchAccessSubjectsContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/access-subjects:search', + method: 'GET', + }) + .input(type<{ + params: { appId: string } + query?: { + keyword?: string + subjectTypes?: string[] + } + }>()) + .output(type()) + +export const patchAccessChannelContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/access-channels/{channel}', + method: 'PATCH', + }) + .input(type<{ + params: { + appId: string + channel: string + } + body: { + channel: string + enabled: boolean + expectedVersion: number + } + }>()) + .output(type()) + +export const createReleaseContract = base + .route({ + path: '/enterprise/apps/{appId}/deploy/releases', + method: 'POST', + }) + .input(type<{ + params: { appId: string } + body: { + description?: string + name: string + } + }>()) + .output(type()) + +export const createDeploymentContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments', + method: 'POST', + }) + .input(type<{ + params: { + appId: string + environmentId: string + } + body: { + releaseId: string + bindings?: BindingsProto + replicas?: number + idempotencyKey?: string + } + }>()) + .output(type()) + +export const cancelDeploymentContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments/{deploymentId}/cancel', + method: 'POST', + }) + .input(type<{ + params: { + appId: string + environmentId: string + deploymentId: string + } + body: { + idempotencyKey?: string + } + }>()) + .output(type()) + +export const undeployEnvironmentContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments:undeploy', + method: 'POST', + }) + .input(type<{ + params: { + appId: string + environmentId: string + } + body: { + idempotencyKey?: string + } + }>()) + .output(type()) + +export const rollbackEnvironmentContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/deployments:rollback', + method: 'POST', + }) + .input(type<{ + params: { + appId: string + environmentId: string + } + body: { + targetReleaseId?: string + idempotencyKey?: string + } + }>()) + .output(type()) + +export const environmentAPITokensContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/api-keys', + method: 'GET', + }) + .input(type<{ + params: { + appId: string + environmentId: string + } + }>()) + .output(type()) + +export const createEnvironmentAPITokenContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/api-keys', + method: 'POST', + }) + .input(type<{ + params: { + appId: string + environmentId: string + } + body: { + name: string + } + }>()) + .output(type()) + +export const deleteEnvironmentAPITokenContract = base + .route({ + path: '/enterprise/apps/{appId}/environments/{environmentId}/api-keys/{apiKeyId}', + method: 'DELETE', + }) + .input(type<{ + params: { + appId: string + environmentId: string + apiKeyId: string + } + }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 086b94f248..7f4125fab5 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -2,6 +2,26 @@ import type { InferContractRouterInputs } from '@orpc/contract' import { accountAvatarContract } from './console/account' import { appDeleteContract, workflowOnlineUsersContract } from './console/apps' import { bindPartnerStackContract, invoicesContract } from './console/billing' +import { + accessConfigContract, + cancelDeploymentContract, + createDeploymentContract, + createEnvironmentAPITokenContract, + createReleaseContract, + deleteEnvironmentAPITokenContract, + deploymentCandidatesContract, + deploymentOverviewContract, + deploymentPlanContract, + environmentAccessPolicyContract, + environmentAPITokensContract, + environmentDeploymentsContract, + patchAccessChannelContract, + releaseHistoryContract, + rollbackEnvironmentContract, + searchAccessSubjectsContract, + undeployEnvironmentContract, + updateEnvironmentAccessPolicyContract, +} from './console/deployments' import { exploreAppDetailContract, exploreAppsContract, @@ -91,6 +111,26 @@ export const consoleRouterContract = { invoices: invoicesContract, bindPartnerStack: bindPartnerStackContract, }, + deployments: { + overview: deploymentOverviewContract, + environmentDeployments: environmentDeploymentsContract, + candidates: deploymentCandidatesContract, + deploymentPlan: deploymentPlanContract, + releaseHistory: releaseHistoryContract, + accessConfig: accessConfigContract, + environmentAccessPolicy: environmentAccessPolicyContract, + updateEnvironmentAccessPolicy: updateEnvironmentAccessPolicyContract, + searchAccessSubjects: searchAccessSubjectsContract, + patchAccessChannel: patchAccessChannelContract, + createRelease: createReleaseContract, + createDeployment: createDeploymentContract, + cancelDeployment: cancelDeploymentContract, + undeployEnvironment: undeployEnvironmentContract, + rollbackEnvironment: rollbackEnvironmentContract, + environmentAPITokens: environmentAPITokensContract, + createEnvironmentAPIToken: createEnvironmentAPITokenContract, + deleteEnvironmentAPIToken: deleteEnvironmentAPITokenContract, + }, workflowDraft: { environmentVariables: workflowDraftEnvironmentVariablesContract, updateEnvironmentVariables: workflowDraftUpdateEnvironmentVariablesContract, diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index 2d8b00b9dc..8f085bc9bd 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -3,11 +3,15 @@ "access.api.description": "Access this instance over HTTP. Each API key is scoped to one environment.", "access.api.developerTitle": "Developer API", "access.api.disabled": "API access is turned off for this instance.", + "access.api.dismissToken": "Dismiss token", "access.api.empty": "Deploy to an environment first to start issuing API keys.", "access.api.envPrefix": "env: {{env}}", "access.api.keyList": "API key list", "access.api.newKey": "New key", "access.api.newKeyForEnv": "Generate for {{env}}", + "access.api.newTokenDescription": "This token is shown only once. Copy it before leaving this page.", + "access.api.newTokenLabel": "Token", + "access.api.newTokenTitle": "API key created", "access.api.noKeys": "No API keys yet. Create one to start calling the API.", "access.api.title": "API", "access.channels.description": "WebApp and CLI entry points use the access permissions above.", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index c2042809f3..f1907b0ba0 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -3,11 +3,15 @@ "access.api.description": "通过 HTTP 调用该实例。每个 API 密钥仅在一个环境中生效。", "access.api.developerTitle": "开发者 API", "access.api.disabled": "该实例的 API 接入已关闭。", + "access.api.dismissToken": "关闭密钥", "access.api.empty": "请先部署到环境后再签发 API 密钥。", "access.api.envPrefix": "env:{{env}}", "access.api.keyList": "API Key 列表", "access.api.newKey": "生成新 Key", "access.api.newKeyForEnv": "为 {{env}} 生成", + "access.api.newTokenDescription": "该明文密钥仅本次显示,请在离开页面前复制保存。", + "access.api.newTokenLabel": "密钥", + "access.api.newTokenTitle": "API Key 已创建", "access.api.noKeys": "尚无 API 密钥,创建一个即可调用 API。", "access.api.title": "API", "access.channels.description": "WebApp 与 CLI 入口遵循上方访问权限。", diff --git a/web/plugins/dev-proxy/cookies.ts b/web/plugins/dev-proxy/cookies.ts index c606322e96..ad087d1549 100644 --- a/web/plugins/dev-proxy/cookies.ts +++ b/web/plugins/dev-proxy/cookies.ts @@ -36,10 +36,15 @@ const toUpstreamCookieName = (cookieName: string) => { const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '') -export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => { +export const rewriteCookieHeaderForUpstream = ( + cookieHeader?: string, + options: { useHostPrefix?: boolean } = {}, +) => { if (!cookieHeader) return cookieHeader + const { useHostPrefix = true } = options + return cookieHeader .split(/;\s*/) .filter(Boolean) @@ -50,7 +55,7 @@ export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => { const cookieName = cookie.slice(0, separatorIndex).trim() const cookieValue = cookie.slice(separatorIndex + 1) - return `${toUpstreamCookieName(cookieName)}=${cookieValue}` + return `${useHostPrefix ? toUpstreamCookieName(cookieName) : cookieName}=${cookieValue}` }) .join('; ') } diff --git a/web/plugins/dev-proxy/server.spec.ts b/web/plugins/dev-proxy/server.spec.ts index eb5c66d614..fb2a7833da 100644 --- a/web/plugins/dev-proxy/server.spec.ts +++ b/web/plugins/dev-proxy/server.spec.ts @@ -109,6 +109,32 @@ describe('dev proxy server', () => { ]) }) + // Scenario: a local HTTP Dify API expects the non-prefixed local cookie name. + it('should keep local cookie names for HTTP upstream targets', async () => { + // Arrange + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok')) + const app = createDevProxyApp({ + consoleApiTarget: 'http://127.0.0.1:5001', + publicApiTarget: 'http://127.0.0.1:5001', + enterpriseApiTarget: 'http://127.0.0.1:8082', + fetchImpl, + }) + + // Act + await app.request('http://127.0.0.1:5010/console/api/account/profile', { + headers: { + Cookie: 'access_token=abc; refresh_token=def', + }, + }) + + // Assert + const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers + if (!(requestHeaders instanceof Headers)) + throw new Error('Expected proxy request headers to be Headers') + + expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def') + }) + // Scenario: Enterprise dashboard routes should use the Enterprise target before generic API routes. it('should proxy enterprise api routes to the enterprise target', async () => { // Arrange diff --git a/web/plugins/dev-proxy/server.ts b/web/plugins/dev-proxy/server.ts index 42cfa10190..9651b32677 100644 --- a/web/plugins/dev-proxy/server.ts +++ b/web/plugins/dev-proxy/server.ts @@ -113,7 +113,9 @@ const createProxyRequestHeaders = (request: Request, targetUrl: URL) => { if (headers.has('origin')) headers.set('origin', targetUrl.origin) - const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined) + const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, { + useHostPrefix: targetUrl.protocol === 'https:', + }) if (rewrittenCookieHeader) headers.set('cookie', rewrittenCookieHeader) diff --git a/web/service/deployments.ts b/web/service/deployments.ts new file mode 100644 index 0000000000..65dd040f41 --- /dev/null +++ b/web/service/deployments.ts @@ -0,0 +1,214 @@ +import type { + AccessSubject, + BindingsProto, + GetAccessConfigReply, + GetDeploymentOverviewReply, + ListDeploymentCandidatesReply, + ListEnvironmentDeploymentsReply, + ListReleaseHistoryReply, +} from '@/contract/console/deployments' +import { consoleClient } from './client' + +const DEPLOYMENT_PAGE_SIZE = 100 + +export type DeploymentAppData = { + appId: string + overview: GetDeploymentOverviewReply + environmentDeployments: ListEnvironmentDeploymentsReply + candidates: ListDeploymentCandidatesReply + releaseHistory: ListReleaseHistoryReply + accessConfig: GetAccessConfigReply +} + +export type CreateDeploymentParams = { + appId: string + environmentId: string + releaseId?: string + releaseNote?: string + bindings?: BindingsProto +} + +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 => { + const input = { params: { appId } } + const [ + overview, + environmentDeployments, + candidates, + releaseHistory, + accessConfig, + ] = await Promise.all([ + consoleClient.deployments.overview(input), + consoleClient.deployments.environmentDeployments({ + ...input, + query: { + pageNumber: 1, + resultsPerPage: DEPLOYMENT_PAGE_SIZE, + }, + }), + consoleClient.deployments.candidates(input), + consoleClient.deployments.releaseHistory({ + ...input, + query: { + pageNumber: 1, + resultsPerPage: DEPLOYMENT_PAGE_SIZE, + }, + }), + consoleClient.deployments.accessConfig(input), + ]) + + return { + appId, + overview, + environmentDeployments, + candidates, + releaseHistory, + accessConfig, + } +} + +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, + releaseId, + releaseNote, + bindings, +}: CreateDeploymentParams) => { + const targetReleaseId = await createOrReuseRelease(appId, releaseId, releaseNote) + return consoleClient.deployments.createDeployment({ + params: { + appId, + environmentId, + }, + body: { + releaseId: targetReleaseId, + bindings, + idempotencyKey: idempotencyKey('deploy'), + }, + }) +} + +export const cancelDeployment = async (appId: string, environmentId: string, deploymentId: string) => { + return consoleClient.deployments.cancelDeployment({ + params: { + appId, + environmentId, + deploymentId, + }, + body: { + idempotencyKey: idempotencyKey('cancel'), + }, + }) +} + +export const undeployEnvironment = async (appId: string, environmentId: string) => { + return consoleClient.deployments.undeployEnvironment({ + params: { + appId, + environmentId, + }, + body: { + idempotencyKey: idempotencyKey('undeploy'), + }, + }) +} + +export const rollbackEnvironment = async (appId: string, environmentId: string, targetReleaseId: string) => { + return consoleClient.deployments.rollbackEnvironment({ + params: { + appId, + environmentId, + }, + body: { + targetReleaseId, + idempotencyKey: idempotencyKey('rollback'), + }, + }) +} + +export const createApiKey = async (appId: string, environmentId: string, name: string) => { + return consoleClient.deployments.createEnvironmentAPIToken({ + params: { + appId, + environmentId, + }, + body: { + name, + }, + }) +} + +export const deleteApiKey = async (appId: string, environmentId: string, apiKeyId: string) => { + return consoleClient.deployments.deleteEnvironmentAPIToken({ + params: { + appId, + environmentId, + apiKeyId, + }, + }) +} + +export const patchAccessChannel = async (appId: string, channel: string, enabled: boolean, expectedVersion = 0) => { + return consoleClient.deployments.patchAccessChannel({ + params: { + appId, + channel, + }, + body: { + channel, + enabled, + expectedVersion, + }, + }) +} + +export const updateEnvironmentAccessPolicy = async ( + appId: string, + environmentId: string, + channel: string, + enabled: boolean, + accessMode: string, + subjects: AccessSubject[] = [], + expectedVersion = 0, +) => { + return consoleClient.deployments.updateEnvironmentAccessPolicy({ + params: { + appId, + environmentId, + channel, + }, + body: { + channel, + enabled, + accessMode, + subjects, + expectedVersion, + }, + }) +}